diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f0f71bad630e..1b21622778fda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2112,6 +2112,9 @@ importers: projects/packages/jetpack-mu-wpcom: dependencies: + '@automattic/color-studio': + specifier: 2.6.0 + version: 2.6.0 '@automattic/i18n-utils': specifier: 1.2.3 version: 1.2.3 @@ -2121,6 +2124,9 @@ importers: '@automattic/jetpack-shared-extension-utils': specifier: workspace:* version: link:../../js-packages/shared-extension-utils + '@automattic/page-pattern-modal': + specifier: 1.1.5 + version: 1.1.5(@types/react@18.3.1)(@wordpress/data@10.2.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) '@automattic/typography': specifier: 1.0.0 version: 1.0.0 @@ -2175,6 +2181,9 @@ importers: preact: specifier: ^10.13.1 version: 10.22.1 + redux: + specifier: ^4.2.1 + version: 4.2.1 wpcom-proxy-request: specifier: ^7.0.3 version: 7.0.5 @@ -4874,6 +4883,13 @@ packages: '@automattic/languages@1.0.0': resolution: {integrity: sha512-froTyDbTmLitHkvY9WLCpFdjUo6moOLkDKw63J2fLiB2gBApy2thkBV+LRx4Z0kIF5iXVkQF4yYOPYkT9Sr13Q==} + '@automattic/page-pattern-modal@1.1.5': + resolution: {integrity: sha512-cFA82qWUDSSFhOHfOkOqh7X8I9As5fNGp7w3LVw7ZDRl6wSiQZveLvWp4msNDnLmeiJTpxWVOZWvCirxYUE3Sw==} + peerDependencies: + '@wordpress/data': ^10.2.0 + react: ^18.2.0 + redux: ^4.2.1 + '@automattic/popup-monitor@1.0.2': resolution: {integrity: sha512-Y4LMfdkV8iDmezu/7Ov/18JaFJ0QAy5vCntiP0S5AhLt4R/kjLtBt4ifNXNbdKTthGxlL17+LJ1bNtHBVCzPwg==} @@ -14631,6 +14647,29 @@ snapshots: dependencies: tslib: 2.5.0 + '@automattic/page-pattern-modal@1.1.5(@types/react@18.3.1)(@wordpress/data@10.2.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1)': + dependencies: + '@automattic/color-studio': 2.6.0 + '@automattic/typography': 1.0.0 + '@wordpress/base-styles': 5.2.0 + '@wordpress/block-editor': 13.2.0(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/blocks': 13.2.0(react@18.3.1) + '@wordpress/components': 28.2.0(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/compose': 7.2.0(react@18.3.1) + '@wordpress/data': 10.2.0(react@18.3.1) + '@wordpress/element': 6.2.0 + '@wordpress/i18n': 5.2.0 + clsx: 2.1.1 + debug: 4.3.4 + lodash: 4.17.21 + react: 18.3.1 + redux: 4.2.1 + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@types/react' + - react-dom + - supports-color + '@automattic/popup-monitor@1.0.2': dependencies: events: 3.3.0 @@ -18470,7 +18509,7 @@ snapshots: '@types/hoist-non-react-statics': 3.3.5 '@types/react': 18.3.1 hoist-non-react-statics: 3.3.2 - redux: 4.1.1 + redux: 4.2.1 '@types/react-router-dom@5.3.3': dependencies: diff --git a/projects/packages/jetpack-mu-wpcom/changelog/port-starter-page-templates b/projects/packages/jetpack-mu-wpcom/changelog/port-starter-page-templates new file mode 100644 index 0000000000000..276b2bc5a4f60 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/port-starter-page-templates @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +MU WPCOM: Port the starter-page-templates feature from ETK diff --git a/projects/packages/jetpack-mu-wpcom/composer.json b/projects/packages/jetpack-mu-wpcom/composer.json index 14e966c6f40de..3bd1049903806 100644 --- a/projects/packages/jetpack-mu-wpcom/composer.json +++ b/projects/packages/jetpack-mu-wpcom/composer.json @@ -62,7 +62,7 @@ }, "autotagger": true, "branch-alias": { - "dev-trunk": "5.52.x-dev" + "dev-trunk": "5.53.x-dev" }, "textdomain": "jetpack-mu-wpcom", "version-constants": { diff --git a/projects/packages/jetpack-mu-wpcom/package.json b/projects/packages/jetpack-mu-wpcom/package.json index 82fb56fe87d3b..539a12260bb60 100644 --- a/projects/packages/jetpack-mu-wpcom/package.json +++ b/projects/packages/jetpack-mu-wpcom/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@automattic/jetpack-mu-wpcom", - "version": "5.52.1-alpha", + "version": "5.53.0-alpha", "description": "Enhances your site with features powered by WordPress.com", "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/jetpack-mu-wpcom/#readme", "bugs": { @@ -45,10 +45,12 @@ "webpack-cli": "4.9.1" }, "dependencies": { + "@automattic/color-studio": "2.6.0", "@automattic/i18n-utils": "1.2.3", "@automattic/jetpack-base-styles": "workspace:*", "@automattic/jetpack-shared-extension-utils": "workspace:*", "@automattic/typography": "1.0.0", + "@automattic/page-pattern-modal": "1.1.5", "@preact/signals": "^1.2.2", "@sentry/browser": "7.80.1", "@tanstack/react-query": "^5.15.5", @@ -68,6 +70,7 @@ "preact": "^10.13.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "redux": "^4.2.1", "wpcom-proxy-request": "^7.0.3" } } diff --git a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php index 4867ca06ca8fc..c1cbcf570f1d7 100644 --- a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php +++ b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php @@ -13,7 +13,7 @@ * Jetpack_Mu_Wpcom main class. */ class Jetpack_Mu_Wpcom { - const PACKAGE_VERSION = '5.52.1-alpha'; + const PACKAGE_VERSION = '5.53.0-alpha'; const PKG_DIR = __DIR__ . '/../'; const BASE_DIR = __DIR__ . '/'; const BASE_FILE = __FILE__; @@ -146,7 +146,7 @@ public static function load_wpcom_user_features() { } /** - * Laod ETK features that need higher priority than the ETK plugin. + * Load ETK features that need higher priority than the ETK plugin. * Can be moved back to load_features() once the feature no longer exists in the ETK plugin. */ public static function load_etk_features() { @@ -165,6 +165,7 @@ public static function load_etk_features() { require_once __DIR__ . '/features/wpcom-documentation-links/wpcom-documentation-links.php'; require_once __DIR__ . '/features/wpcom-global-styles/index.php'; require_once __DIR__ . '/features/wpcom-whats-new/wpcom-whats-new.php'; + require_once __DIR__ . '/features/starter-page-templates/class-starter-page-templates.php'; } /** diff --git a/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/class-wpcom-block-patterns-from-api.php b/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/class-wpcom-block-patterns-from-api.php index a7d0de0e6299f..78a31b75cfeb9 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/class-wpcom-block-patterns-from-api.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/block-patterns/class-wpcom-block-patterns-from-api.php @@ -103,7 +103,7 @@ public function register_patterns() { // We prefer to show the starter page patterns modal of wpcom instead of core // if it's available. Hence, we have to update the block types of patterns // to disable the core's. - if ( class_exists( '\A8C\FSE\Starter_Page_Templates' ) ) { + if ( class_exists( '\A8C\FSE\Starter_Page_Templates' ) || class_exists( '\Automattic\Jetpack\Jetpack_Mu_Wpcom\Starter_Page_Templates' ) ) { $this->update_pattern_block_types(); } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/class-starter-page-templates.php b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/class-starter-page-templates.php new file mode 100644 index 0000000000000..395c21e967e24 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/class-starter-page-templates.php @@ -0,0 +1,448 @@ +get_templates_cache_key() getter. + * + * @var string + */ + public $templates_cache_key = 'starter_page_templates'; + + /** + * Starter_Page_Templates constructor. + */ + private function __construct() { + // We don't want the user to choose a template when copying a post. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['jetpack-copy'] ) ) { + return; + } + + /** + * Can be used to disable the Starter Page Templates. + * + * @param bool true if Starter Page Templates should be disabled, false otherwise. + */ + if ( apply_filters( 'a8c_disable_starter_page_templates', false ) ) { + return; + } + + // Register post metas for Launchpad newsletter task and template tracking + add_action( 'init', array( $this, 'register_meta_field' ) ); + // Enqueue scripts and pass templates in a global JS variable + add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) ); + // Sideload images to add them to the Media Library for Gallery block images to work + add_action( 'rest_api_init', array( $this, 'register_rest_api' ) ); + // Clean caches + add_action( 'delete_attachment', array( $this, 'clear_sideloaded_image_cache' ) ); + add_action( 'switch_theme', array( $this, 'clear_templates_cache' ) ); + // Handle styles for classic themes + add_action( 'block_editor_settings_all', array( $this, 'add_default_editor_styles_for_classic_themes' ), 10, 2 ); + } + + /** + * Gets the cache key for templates array. + * + * @param string $locale The templates locale. + * + * @return string + */ + public function get_templates_cache_key( string $locale ) { + return $this->templates_cache_key . '_' . $locale; + } + + /** + * Creates instance. + * + * @return \Automattic\Jetpack\Jetpack_Mu_Wpcom\Starter_Page_Templates + */ + public static function get_instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Register meta field for storing the template identifier. + */ + public function register_meta_field() { + $args = array( + 'type' => 'string', + 'description' => 'Selected template', + 'single' => true, + 'show_in_rest' => true, + 'object_subtype' => 'page', + 'auth_callback' => function () { + return current_user_can( 'edit_posts' ); + }, + ); + register_meta( 'post', '_starter_page_template', $args ); + + $args = array( + 'type' => 'array', + 'description' => 'Selected category', + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + 'single' => true, + 'object_subtype' => 'page', + 'auth_callback' => function () { + return current_user_can( 'edit_pages' ); + }, + 'sanitize_callback' => function ( $meta_value ) { + if ( ! is_array( $meta_value ) ) { + return array(); + } + + if ( ! class_exists( '\Automattic\Jetpack\Jetpack_Mu_Wpcom\Starter_Page_Templates' ) ) { + return array(); + } + + $starter_page_templates = \Automattic\Jetpack\Jetpack_Mu_Wpcom\Starter_Page_Templates::get_instance(); + // We need to pass a locale in here, but we don't actually depend on it, so we use the default site locale to optimise hitting the pattern cache for the site. + $all_page_templates = $starter_page_templates->get_page_templates( $starter_page_templates->get_verticals_locale() ); + $all_categories = array_merge( ...array_map( 'array_keys', wp_list_pluck( $all_page_templates, 'categories' ) ) ); + + $unique_categories = array_unique( $all_categories ); + + // Only permit values that are valid categories. + return array_intersect( $meta_value, $unique_categories ); + }, + ); + register_meta( 'post', '_wpcom_template_layout_category', $args ); + } + + /** + * Register rest api endpoint for side-loading images. + */ + public function register_rest_api() { + require_once __DIR__ . '/class-wp-rest-sideload-image-controller.php'; + + ( new WP_REST_Sideload_Image_Controller() )->register_routes(); + } + + /** + * Pass error message to frontend JavaScript console. + * + * @param string $message Error message. + */ + public function pass_error_to_frontend( $message ) { + wp_register_script( + 'starter-page-templates-error', + false, + array(), + '1.O', + true + ); + wp_add_inline_script( + 'starter-page-templates-error', + sprintf( + 'console.warn(%s);', + wp_json_encode( $message ) + ) + ); + wp_enqueue_script( 'starter-page-templates-error' ); + } + + /** + * Enqueue block editor assets. + */ + public function enqueue_assets() { + $screen = get_current_screen(); + $user_locale = Common\get_iso_639_locale( get_user_locale() ); + + // Return early if we don't meet conditions to show templates. + if ( 'page' !== $screen->id ) { + return; + } + + // Load templates for this site. + $page_templates = $this->get_page_templates( $this->get_verticals_locale() ); + if ( $user_locale !== $this->get_verticals_locale() ) { + // If the user locale is not the blog locale, we should show labels in the user locale. + $user_page_templates_indexed = array(); + $user_page_templates = $this->get_page_templates( $user_locale ); + foreach ( $user_page_templates as $page_template ) { + if ( ! empty( $page_template['ID'] ) ) { + $user_page_templates_indexed[ $page_template['ID'] ] = $page_template; + } + } + foreach ( $page_templates as $key => $page_template ) { + if ( isset( $user_page_templates_indexed[ $page_template['ID'] ]['categories'] ) ) { + $page_templates[ $key ]['categories'] = $user_page_templates_indexed[ $page_template['ID'] ]['categories']; + } + if ( isset( $user_page_templates_indexed[ $page_template['ID'] ]['description'] ) ) { + $page_templates[ $key ]['description'] = $user_page_templates_indexed[ $page_template['ID'] ]['description']; + } + } + } + + // Hide non-user-facing categories (Pages, Virtual Theme, and wordpress.com/patterns homepage) in modal + $hidden_categories = array( 'page', 'virtual-theme', '_public_library_homepage' ); + foreach ( $page_templates as &$page_template ) { + if ( ! isset( $page_template['categories'] ) ) { + continue; + } + foreach ( $page_template['categories'] as $category ) { + if ( in_array( $category['slug'], $hidden_categories, true ) ) { + unset( $page_template['categories'][ $category['slug'] ] ); + } + } + } + + if ( empty( $page_templates ) ) { + $this->pass_error_to_frontend( __( 'No templates available. Skipped showing modal window with template selection.', 'jetpack-mu-wpcom' ) ); + return; + } + + $handle = jetpack_mu_wpcom_enqueue_assets( 'starter-page-templates', array( 'js', 'css' ) ); + wp_set_script_translations( $handle, 'jetpack-mu-wpcom' ); + + $default_templates = array( + array( + 'ID' => null, + 'title' => __( 'Blank', 'jetpack-mu-wpcom' ), + 'name' => 'blank', + ), + array( + 'ID' => null, + 'title' => __( 'Current', 'jetpack-mu-wpcom' ), + 'name' => 'current', + ), + ); + + $registered_page_templates = $this->get_registered_page_templates(); + + /** + * Filters the config before it's passed to the frontend. + * + * @param array $config The config. + */ + $config = apply_filters( + 'fse_starter_page_templates_config', + array( + 'templates' => array_merge( $default_templates, $registered_page_templates, $page_templates ), + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + 'screenAction' => isset( $_GET['new-homepage'] ) ? 'add' : $screen->action, + ) + ); + + $config = wp_json_encode( $config ); + + wp_add_inline_script( + $handle, + "var starterPageTemplatesConfig = $config;", + 'before' + ); + } + + /** + * Get page templates from the patterns API or return cached version if available. + * + * @param string $locale The templates locale. + * + * @return array Containing page templates or nothing if an error occurred. + */ + public function get_page_templates( string $locale ) { + $page_template_data = get_transient( $this->get_templates_cache_key( $locale ) ); + $override_source_site = apply_filters( 'a8c_override_patterns_source_site', false ); + $disable_cache = function_exists( 'is_automattician' ) && is_automattician() || false !== $override_source_site || ( defined( 'WP_DISABLE_PATTERN_CACHE' ) && WP_DISABLE_PATTERN_CACHE ); + + // Load fresh data if is automattician or we don't have any data. + if ( $disable_cache || false === $page_template_data ) { + $request_url = esc_url_raw( + add_query_arg( + array( + 'site' => $override_source_site ?? 'dotcompatterns.wordpress.com', + 'categories' => 'page', + 'post_type' => 'wp_block', + ), + 'https://public-api.wordpress.com/rest/v1/ptk/patterns/' . $locale + ) + ); + + $args = array( 'timeout' => 20 ); + + if ( function_exists( 'wpcom_json_api_get' ) ) { + $response = wpcom_json_api_get( $request_url, $args ); + } else { + $response = wp_remote_get( $request_url, $args ); + } + + if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { + return array(); + } + + $page_template_data = json_decode( wp_remote_retrieve_body( $response ), true ); + + // Only save to cache when is not disabled. + if ( ! $disable_cache ) { + set_transient( $this->get_templates_cache_key( $locale ), $page_template_data, 5 * MINUTE_IN_SECONDS ); + } + + return $page_template_data; + } + + return $page_template_data; + } + + /** + * Deletes cached attachment data when attachment gets deleted. + * + * @param int $id Attachment ID of the attachment to be deleted. + */ + public function clear_sideloaded_image_cache( $id ) { + $url = get_post_meta( $id, '_sideloaded_url', true ); + if ( ! empty( $url ) ) { + delete_transient( 'fse_sideloaded_image_' . hash( 'crc32b', $url ) ); + } + } + + /** + * Deletes cached templates data when theme switches. + */ + public function clear_templates_cache() { + delete_transient( $this->get_templates_cache_key( $this->get_verticals_locale() ) ); + } + + /** + * Gets the locale to be used for fetching the site vertical + */ + public function get_verticals_locale() { + // Make sure to get blog locale, not user locale. + $language = function_exists( 'get_blog_lang_code' ) ? get_blog_lang_code() : get_locale(); + return Common\get_iso_639_locale( $language ); + } + + /** + * Gets the registered page templates + */ + public function get_registered_page_templates() { + $registered_page_templates = array(); + + if ( class_exists( 'WP_Block_Patterns_Registry' ) ) { + $registered_categories = $this->get_registered_categories(); + foreach ( \WP_Block_Patterns_Registry::get_instance()->get_all_registered() as $pattern ) { + if ( ! array_key_exists( 'blockTypes', $pattern ) ) { + continue; + } + + $post_content_offset = array_search( 'core/post-content', $pattern['blockTypes'], true ); + if ( $post_content_offset !== false ) { + $categories = array(); + foreach ( $pattern['categories'] as $category ) { + $registered_category = $registered_categories[ $category ]; + if ( $registered_category ) { + $categories[ $category ] = array( + 'slug' => $registered_category['name'], + 'title' => $registered_category['label'], + 'description' => $registered_category['description'], + ); + } + } + + $registered_page_templates[] = array( + 'ID' => null, + 'title' => $pattern['title'], + 'description' => $pattern['description'], + 'name' => $pattern['name'], + 'html' => $pattern['content'], + 'categories' => $categories, + ); + } + } + } + + return $registered_page_templates; + } + + /** + * Gets the registered categories. + */ + public function get_registered_categories() { + $registered_categories = array(); + + if ( class_exists( 'WP_Block_Pattern_Categories_Registry' ) ) { + foreach ( \WP_Block_Pattern_Categories_Registry::get_instance()->get_all_registered() as $category ) { + $registered_categories[ $category['name'] ] = $category; + } + } + + return $registered_categories; + } + + /** + * Fix for text overlapping on the page patterns preview on classic themes. + * + * @param array $editor_settings Editor settings. + * @param object $editor_context Editor context. + * + * Only for classic themes because the default styles for block themes include a line-height for the body. + * This issue would not exist if the WordPress wp-admin common.css for the body element (line-height: 1.4em) + * does not overwrite the Gutenberg block-library reset.css for .editor-styles-wrapper (line-height: normal). + * + * This fix adds the default editor styles as custom styles in the editor settings. These are used in the + * editor canvas (.editor-styles-wrapper) and pattern previews (BlockPreview). + * Custom styles are safe because they are overwritten by local block styles, global styles, or theme stylesheets. + **/ + public function add_default_editor_styles_for_classic_themes( $editor_settings, $editor_context ) { + $theme = wp_get_theme( get_stylesheet() ); + if ( $theme->is_block_theme() ) { + // Only for classic themes + return $editor_settings; + } + if ( 'core/edit-post' !== $editor_context->name || 'page' !== $editor_context->post->post_type ) { + // Only for page editor + return $editor_settings; + } + if ( ! function_exists( 'gutenberg_dir_path' ) ) { + return $editor_settings; + } + + $default_editor_styles_file = gutenberg_dir_path() . 'build/block-editor/default-editor-styles.css'; // @phan-suppress-current-line PhanUndeclaredFunction + if ( ! file_exists( $default_editor_styles_file ) ) { + return $editor_settings; + } + $default_editor_styles = file_get_contents( $default_editor_styles_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + + $editor_settings['styles'][] = array( + 'css' => $default_editor_styles, + ); + + return $editor_settings; + } +} + +// Initialization +\Automattic\Jetpack\Jetpack_Mu_Wpcom\Starter_Page_Templates::get_instance(); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/class-wp-rest-sideload-image-controller.php b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/class-wp-rest-sideload-image-controller.php new file mode 100644 index 0000000000000..ea547348d731b --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/class-wp-rest-sideload-image-controller.php @@ -0,0 +1,295 @@ +namespace = 'fse/v1'; + $this->rest_base = 'sideload/image'; + } + + /** + * Register available routes. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, // @phan-suppress-current-line PhanPluginMixedKeyNoKey + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'show_in_index' => false, + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/batch', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_items' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'show_in_index' => false, + 'args' => array( + 'resources' => array( + 'description' => 'URL to the image to be side-loaded.', + 'type' => 'array', + 'required' => true, + 'items' => array( + 'type' => 'object', + 'properties' => $this->get_collection_params(), + ), + ), + ), + ), + ) + ); + } + + /** + * Creates a single attachment. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_Error|\WP_REST_Response Response object on success, WP_Error object on failure. + */ + public function create_item( $request ) { + if ( ! empty( $request['post_id'] ) && in_array( get_post_type( $request['post_id'] ), array( 'revision', 'attachment' ), true ) ) { + return new \WP_Error( 'rest_invalid_param', __( 'Invalid parent type.', 'jetpack-mu-wpcom' ), array( 'status' => 400 ) ); + } + + $inserted = false; + $attachment = $this->get_attachment( $request->get_param( 'url' ) ); + if ( ! $attachment ) { + // Include image functions to get access to wp_read_image_metadata(). + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + + // The post ID on success, WP_Error on failure. + $id = media_sideload_image( + $request->get_param( 'url' ), + $request->get_param( 'post_id' ), + '', + 'id' + ); + + if ( is_wp_error( $id ) ) { + if ( 'db_update_error' === $id->get_error_code() ) { + $id->add_data( array( 'status' => 500 ) ); + } else { + $id->add_data( array( 'status' => 400 ) ); + } + + return rest_ensure_response( $id ); // Return error. + } + + $attachment = get_post( $id ); + + /** + * Fires after a single attachment is created or updated via the REST API. + * + * @param WP_Post $attachment Inserted or updated attachment object. + * @param WP_REST_Request $request The request sent to the API. + * @param bool $creating True when creating an attachment, false when updating. + */ + do_action( 'rest_insert_attachment', $attachment, $request, true ); + + if ( isset( $request['alt_text'] ) ) { + update_post_meta( $id, '_wp_attachment_image_alt', sanitize_text_field( $request['alt_text'] ) ); + } + + update_post_meta( $id, '_sideloaded_url', $request->get_param( 'url' ) ); + + $fields_update = $this->update_additional_fields_for_object( $attachment, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + $inserted = true; + $request->set_param( 'context', 'edit' ); + + /** + * Fires after a single attachment is completely created or updated via the REST API. + * + * @param WP_Post $attachment Inserted or updated attachment object. + * @param WP_REST_Request $request Request object. + * @param bool $creating True when creating an attachment, false when updating. + */ + do_action( 'rest_after_insert_attachment', $attachment, $request, true ); + } + + $response = $this->prepare_item_for_response( $attachment, $request ); // @phan-suppress-current-line PhanTypeMismatchArgumentNullable + $response = rest_ensure_response( $response ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', 'wp/v2', 'media', $attachment->ID ) ) ); + + if ( $inserted ) { + $response->set_status( 201 ); + } + + return $response; + } + + /** + * Creates a batch of attachments. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_Error|\WP_REST_Response Response object on success, WP_Error object on failure. + */ + public function create_items( $request ) { + $data = array(); + + // Foreach request specified in the requests param, run the endpoint. + foreach ( $request['resources'] as $resource ) { + $request = new \WP_REST_Request( 'POST', $this->get_item_route() ); + + // Add specified request parameters into the request. + foreach ( $resource as $param_name => $param_value ) { + $request->set_param( $param_name, $param_value ); + } + + $response = rest_do_request( $request ); + $data[] = $this->prepare_for_collection( $response ); + } + + return rest_ensure_response( $data ); + } + + /** + * Prepare a response for inserting into a collection of responses. + * + * @param \WP_REST_Response $response Response object. + * @return array|\WP_REST_Response Response data, ready for insertion into collection data. + */ + public function prepare_for_collection( $response ) { + if ( ! ( $response instanceof \WP_REST_Response ) ) { + return $response; + } + + $data = (array) $response->get_data(); + $server = rest_get_server(); + + if ( method_exists( $server, 'get_compact_response_links' ) ) { + $links = call_user_func( array( $server, 'get_compact_response_links' ), $response ); + } else { + $links = call_user_func( array( $server, 'get_response_links' ), $response ); + } + + if ( ! empty( $links ) ) { + $data['_links'] = $links; + } + + return $data; + } + + /** + * Prepares a single attachment output for response. + * + * @param \WP_Post $post Attachment object. + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response Response object. + */ + public function prepare_item_for_response( $post, $request ) { + $response = parent::prepare_item_for_response( $post, $request ); + $base = 'wp/v2/media'; + + foreach ( array( 'self', 'collection', 'about' ) as $link ) { + $response->remove_link( $link ); + + } + + $response->add_link( 'self', rest_url( trailingslashit( $base ) . $post->ID ) ); // @phan-suppress-current-line PhanAccessMethodInternal + $response->add_link( 'collection', rest_url( $base ) ); // @phan-suppress-current-line PhanAccessMethodInternal + $response->add_link( 'about', rest_url( 'wp/v2/types/' . $post->post_type ) ); // @phan-suppress-current-line PhanAccessMethodInternal + + return $response; + } + + /** + * Gets the attachment if an image has been sideloaded previously. + * + * @param string $url URL of the image to sideload. + * @return object|bool Attachment object on success, false on failure. + */ + public function get_attachment( $url ) { + $cache_key = 'fse_sideloaded_image_' . hash( 'crc32b', $url ); + $attachment = get_transient( $cache_key ); + + if ( false === $attachment ) { + $attachments = new \WP_Query( + array( + 'no_found_rows' => true, + 'posts_per_page' => 1, + 'post_status' => 'inherit', + 'post_type' => 'attachment', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_sideloaded_url', + 'value' => $url, + ), + ), + ) + ); + + if ( $attachments->have_posts() ) { + set_transient( $cache_key, $attachments->post ); + } + } + + return $attachment; + } + + /** + * Returns the endpoints request parameters. + * + * @return array Request parameters. + */ + public function get_collection_params() { + return array( + 'url' => array( + 'description' => 'URL to the image to be side-loaded.', + 'type' => 'string', + 'required' => true, + 'format' => 'uri', + 'sanitize_callback' => function ( $url ) { + return esc_url_raw( strtok( $url, '?' ) ); + }, + ), + 'post_id' => array( + 'description' => 'ID of the post to associate the image with', + 'type' => 'integer', + 'default' => 0, + ), + ); + } + + /** + * Returns the route to sideload a single image. + * + * @return string + */ + public function get_item_route() { + return "/{$this->namespace}/{$this->rest_base}"; + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/index.scss b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/index.scss new file mode 100644 index 0000000000000..45002b1f0750e --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/index.scss @@ -0,0 +1,34 @@ +@import "@automattic/page-pattern-modal/src/styles/page-pattern-modal"; + +.sidebar-modal-opener { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + .pattern-selector-item__label { + max-width: 300px; + } +} + +.sidebar-modal-opener__warning-modal { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.sidebar-modal-opener__warning-text { + max-width: 300px; + font-size: 1rem; + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ + line-height: 1.5rem; +} + +.sidebar-modal-opener__warning-options { + float: right; + margin-top: 20px; + + .components-button { + margin-left: 12px; + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/index.tsx b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/index.tsx new file mode 100644 index 0000000000000..1f6422e776671 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/index.tsx @@ -0,0 +1,44 @@ +import { initializeTracksWithIdentity, PatternDefinition } from '@automattic/page-pattern-modal'; +import { dispatch } from '@wordpress/data'; +import { registerPlugin } from '@wordpress/plugins'; +import { PagePatternsPlugin } from './page-patterns-plugin'; +import { pageLayoutStore } from './store'; +import './index.scss'; + +declare global { + interface Window { + starterPageTemplatesConfig?: { + templates?: PatternDefinition[]; + screenAction?: string; + tracksUserData?: Parameters< typeof initializeTracksWithIdentity >[ 0 ]; + }; + } +} + +// Load config passed from backend. +const { + templates: patterns = [], + tracksUserData, + screenAction, +} = window.starterPageTemplatesConfig ?? {}; + +if ( tracksUserData ) { + initializeTracksWithIdentity( tracksUserData ); +} + +// Open plugin only if we are creating new page. +if ( screenAction === 'add' ) { + dispatch( pageLayoutStore ).setOpenState( 'OPEN_FROM_ADD_PAGE' ); +} + +// Always register ability to open from document sidebar. +registerPlugin( 'page-patterns', { + render: () => { + return ; + }, + + // `registerPlugin()` types assume `icon` is mandatory however it isn't + // actually required. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + icon: undefined as any, +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/page-patterns-plugin.tsx b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/page-patterns-plugin.tsx new file mode 100644 index 0000000000000..0eb6d3f32e52d --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/page-patterns-plugin.tsx @@ -0,0 +1,134 @@ +import { PagePatternModal, PatternDefinition } from '@automattic/page-pattern-modal'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useCallback } from '@wordpress/element'; +import { addFilter, removeFilter } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; +import { pageLayoutStore } from './store'; +import '@wordpress/nux'; + +const INSERTING_HOOK_NAME = 'isInsertingPagePattern'; +const INSERTING_HOOK_NAMESPACE = 'automattic/full-site-editing/inserting-pattern'; + +interface PagePatternsPluginProps { + patterns: PatternDefinition[]; +} +type CoreEditorPlaceholder = { + getBlocks: ( ...args: unknown[] ) => Array< { name: string; clientId: string } >; + getEditedPostAttribute: ( ...args: unknown[] ) => unknown; +}; +type CoreEditPostPlaceholder = { + isFeatureActive: ( ...args: unknown[] ) => boolean; +}; +type CoreNuxPlaceholder = { + areTipsEnabled: ( ...args: unknown[] ) => boolean; +}; + +/** + * Starter page templates feature plugin + * + * @param props - An object that receives the page patterns + */ +export function PagePatternsPlugin( props: PagePatternsPluginProps ) { + const { setOpenState } = useDispatch( pageLayoutStore ); + const { setUsedPageOrPatternsModal } = useDispatch( 'automattic/wpcom-welcome-guide' ); + const { replaceInnerBlocks } = useDispatch( 'core/block-editor' ); + const { editPost } = useDispatch( 'core/editor' ); + const { toggleFeature } = useDispatch( 'core/edit-post' ); + const { disableTips } = useDispatch( 'core/nux' ); + + const selectProps = useSelect( select => { + const { isOpen, isPatternPicker } = select( pageLayoutStore ); + return { + isOpen: isOpen(), + isWelcomeGuideActive: ( + select( 'core/edit-post' ) as CoreEditPostPlaceholder + ).isFeatureActive( 'welcomeGuide' ) as boolean, // Gutenberg 7.2.0 or higher + areTipsEnabled: select( 'core/nux' ) + ? ( ( select( 'core/nux' ) as CoreNuxPlaceholder ).areTipsEnabled() as boolean ) + : false, // Gutenberg 7.1.0 or lower + ...( isPatternPicker() && { + title: __( 'Choose a Pattern', 'jetpack-mu-wpcom' ), + description: __( + 'Pick a pre-defined layout or continue with a blank page', + 'jetpack-mu-wpcom' + ), + } ), + }; + }, [] ); + + const { getMeta, postContentBlock } = useSelect( select => { + const getMetaNew = () => + ( select( 'core/editor' ) as CoreEditorPlaceholder ).getEditedPostAttribute( 'meta' ); + const currentBlocks = ( select( 'core/editor' ) as CoreEditorPlaceholder ).getBlocks(); + return { + getMeta: getMetaNew, + postContentBlock: currentBlocks.find( block => block.name === 'a8c/post-content' ), + }; + }, [] ); + + const savePatternChoice = useCallback( + ( name: string, selectedCategory: string | null ) => { + // Save selected pattern slug in meta. + const currentMeta = getMeta() as Record< string, unknown >; + const currentCategory = + ( Array.isArray( currentMeta._wpcom_template_layout_category ) && + currentMeta._wpcom_template_layout_category ) || + []; + editPost( { + meta: { + ...currentMeta, + _starter_page_template: name, + _wpcom_template_layout_category: [ ...currentCategory, selectedCategory ], + }, + } ); + }, + [ editPost, getMeta ] + ); + + const insertPattern = useCallback( + ( title: string | null, blocks: unknown[] ) => { + // Add filter to let the tracking library know we are inserting a template. + addFilter( INSERTING_HOOK_NAME, INSERTING_HOOK_NAMESPACE, () => true ); + + // Set post title. + if ( title ) { + editPost( { title } ); + } + + // Replace blocks. + replaceInnerBlocks( postContentBlock ? postContentBlock.clientId : '', blocks, false ); + + // Remove filter. + removeFilter( INSERTING_HOOK_NAME, INSERTING_HOOK_NAMESPACE ); + }, + [ editPost, postContentBlock, replaceInnerBlocks ] + ); + + const { isWelcomeGuideActive, areTipsEnabled } = selectProps; + + const hideWelcomeGuide = useCallback( () => { + if ( isWelcomeGuideActive ) { + // Gutenberg 7.2.0 or higher. + toggleFeature( 'welcomeGuide' ); + } else if ( areTipsEnabled ) { + // Gutenberg 7.1.0 or lower. + disableTips(); + } + }, [ areTipsEnabled, disableTips, isWelcomeGuideActive, toggleFeature ] ); + + const handleClose = useCallback( () => { + setOpenState( 'CLOSED' ); + setUsedPageOrPatternsModal?.(); + }, [ setOpenState, setUsedPageOrPatternsModal ] ); + + return ( + + ); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/store.ts b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/store.ts new file mode 100644 index 0000000000000..93bcaf2542eac --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/starter-page-templates/store.ts @@ -0,0 +1,25 @@ +import { register, createReduxStore } from '@wordpress/data'; + +type OpenState = 'CLOSED' | 'OPEN_FROM_ADD_PAGE' | 'OPEN_FOR_BLANK_CANVAS'; + +const reducer = ( state = 'CLOSED', { type, ...action } ) => + 'SET_IS_OPEN' === type ? action.openState : state; + +const actions = { + setOpenState: ( openState: OpenState | false ) => ( { + type: 'SET_IS_OPEN' as const, + openState: openState || 'CLOSED', + } ), +}; + +export const selectors = { + isOpen: ( state: OpenState ): boolean => 'CLOSED' !== state, + isPatternPicker: ( state: OpenState ): boolean => 'OPEN_FOR_BLANK_CANVAS' === state, +}; + +export const pageLayoutStore = createReduxStore( 'automattic/starter-page-layouts', { + reducer, + actions, + selectors, +} ); +register( pageLayoutStore ); diff --git a/projects/packages/jetpack-mu-wpcom/src/utils.php b/projects/packages/jetpack-mu-wpcom/src/utils.php index 7d2b01300d639..93bce198060c1 100644 --- a/projects/packages/jetpack-mu-wpcom/src/utils.php +++ b/projects/packages/jetpack-mu-wpcom/src/utils.php @@ -89,12 +89,13 @@ function wpcom_get_calypso_origin() { * @param array $asset_types The types of the asset. */ function jetpack_mu_wpcom_enqueue_assets( $asset_name, $asset_types = array() ) { - $asset_file = include Jetpack_Mu_Wpcom::BASE_DIR . "build/$asset_name/$asset_name.asset.php"; + $asset_handle = "jetpack-mu-wpcom-$asset_name"; + $asset_file = include Jetpack_Mu_Wpcom::BASE_DIR . "build/$asset_name/$asset_name.asset.php"; if ( in_array( 'js', $asset_types, true ) ) { $js_file = "build/$asset_name/$asset_name.js"; wp_enqueue_script( - "jetpack-mu-wpcom-$asset_name-script", + $asset_handle, plugins_url( $js_file, Jetpack_Mu_Wpcom::BASE_FILE ), $asset_file['dependencies'] ?? array(), $asset_file['version'] ?? filemtime( Jetpack_Mu_Wpcom::BASE_DIR . $js_file ), @@ -106,10 +107,12 @@ function jetpack_mu_wpcom_enqueue_assets( $asset_name, $asset_types = array() ) $css_ext = is_rtl() ? 'rtl.css' : 'css'; $css_file = "build/$asset_name/$asset_name.$css_ext"; wp_enqueue_style( - "jetpack-mu-wpcom-$asset_name-style", + $asset_handle, plugins_url( $css_file, Jetpack_Mu_Wpcom::BASE_FILE ), array(), filemtime( Jetpack_Mu_Wpcom::BASE_DIR . $css_file ) ); } + + return $asset_handle; } diff --git a/projects/packages/jetpack-mu-wpcom/webpack.config.js b/projects/packages/jetpack-mu-wpcom/webpack.config.js index dca79c2a1fd0a..2e89e3d249467 100644 --- a/projects/packages/jetpack-mu-wpcom/webpack.config.js +++ b/projects/packages/jetpack-mu-wpcom/webpack.config.js @@ -40,6 +40,7 @@ module.exports = [ 'wpcom-plugins-banner': './src/features/wpcom-plugins/js/banner.js', 'wpcom-plugins-banner-style': './src/features/wpcom-plugins/css/banner.css', 'wpcom-sidebar-notice': './src/features/wpcom-sidebar-notice/wpcom-sidebar-notice.js', + 'starter-page-templates': './src/features/starter-page-templates/index.tsx', }, mode: jetpackWebpackConfig.mode, devtool: jetpackWebpackConfig.devtool, @@ -62,6 +63,11 @@ module.exports = [ plugins: [ ...jetpackWebpackConfig.StandardPlugins( { MiniCssExtractPlugin: { filename: '[name]/[name].css' }, + DefinePlugin: { + // __i18n_text_domain__ is used in page-pattern-modal npm package, which is used only by starter-page-templates feature. + // Consider moving page-pattern-modal package to starter-page-templates and remove this. + __i18n_text_domain__: JSON.stringify( 'jetpack-mu-wpcom' ), + }, } ), ], module: { diff --git a/projects/plugins/mu-wpcom-plugin/changelog/HEAD b/projects/plugins/mu-wpcom-plugin/changelog/HEAD new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/mu-wpcom-plugin/changelog/HEAD @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + + diff --git a/projects/plugins/mu-wpcom-plugin/changelog/port-starter-page-templates b/projects/plugins/mu-wpcom-plugin/changelog/port-starter-page-templates new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/mu-wpcom-plugin/changelog/port-starter-page-templates @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + + diff --git a/projects/plugins/mu-wpcom-plugin/changelog/port-starter-page-templates#2 b/projects/plugins/mu-wpcom-plugin/changelog/port-starter-page-templates#2 new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/mu-wpcom-plugin/changelog/port-starter-page-templates#2 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + + diff --git a/projects/plugins/mu-wpcom-plugin/changelog/port-starter-page-templates#3 b/projects/plugins/mu-wpcom-plugin/changelog/port-starter-page-templates#3 new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/mu-wpcom-plugin/changelog/port-starter-page-templates#3 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + + diff --git a/projects/plugins/mu-wpcom-plugin/composer.lock b/projects/plugins/mu-wpcom-plugin/composer.lock index 54cdd560ac5fb..77769b61f9d56 100644 --- a/projects/plugins/mu-wpcom-plugin/composer.lock +++ b/projects/plugins/mu-wpcom-plugin/composer.lock @@ -1005,7 +1005,7 @@ "dist": { "type": "path", "url": "../../packages/jetpack-mu-wpcom", - "reference": "a9ffd2d847bb68899b38f0b6916a33e917fe2c90" + "reference": "87d053c8aeb9e5c4da954bff7081b36977ce3ad2" }, "require": { "automattic/jetpack-assets": "@dev", @@ -1039,7 +1039,7 @@ }, "autotagger": true, "branch-alias": { - "dev-trunk": "5.52.x-dev" + "dev-trunk": "5.53.x-dev" }, "textdomain": "jetpack-mu-wpcom", "version-constants": { diff --git a/projects/plugins/wpcomsh/changelog/HEAD b/projects/plugins/wpcomsh/changelog/HEAD new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/HEAD @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + + diff --git a/projects/plugins/wpcomsh/changelog/port-starter-page-templates b/projects/plugins/wpcomsh/changelog/port-starter-page-templates new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/port-starter-page-templates @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + + diff --git a/projects/plugins/wpcomsh/changelog/port-starter-page-templates#2 b/projects/plugins/wpcomsh/changelog/port-starter-page-templates#2 new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/port-starter-page-templates#2 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + + diff --git a/projects/plugins/wpcomsh/changelog/port-starter-page-templates#3 b/projects/plugins/wpcomsh/changelog/port-starter-page-templates#3 new file mode 100644 index 0000000000000..9aa70e3ec1f75 --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/port-starter-page-templates#3 @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Updated composer.lock. + + diff --git a/projects/plugins/wpcomsh/composer.lock b/projects/plugins/wpcomsh/composer.lock index 7544fbbb8f3c2..2ccd641c15511 100644 --- a/projects/plugins/wpcomsh/composer.lock +++ b/projects/plugins/wpcomsh/composer.lock @@ -1142,7 +1142,7 @@ "dist": { "type": "path", "url": "../../packages/jetpack-mu-wpcom", - "reference": "a9ffd2d847bb68899b38f0b6916a33e917fe2c90" + "reference": "87d053c8aeb9e5c4da954bff7081b36977ce3ad2" }, "require": { "automattic/jetpack-assets": "@dev", @@ -1176,7 +1176,7 @@ }, "autotagger": true, "branch-alias": { - "dev-trunk": "5.52.x-dev" + "dev-trunk": "5.53.x-dev" }, "textdomain": "jetpack-mu-wpcom", "version-constants": {