From 408c1186af35e19ed040b8fa56765b1ac731969d Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Fri, 2 Feb 2024 22:42:31 +0000 Subject: [PATCH 01/31] Update Font Library non-REST API code to align with Core standards (#58607) * Apply fixes. * Revert "Apply fixes." This reverts commit d16d22603c3dfd6b957ba672a40eed0e0bec25fa. * Remove variable. * Small fixes. * Fix phpdoc block. * Fix typo. * Apply reverted fixes. * Apply reverted fixes. * Apply reverted fixes. * Apply reverted fixes. * Apply reverted fixes. * Initializes rest routes. --------- Co-authored-by: Anton Vlasenko Co-authored-by: Anton Vlasenko Unlinked contributors: anton@antons-mac-mini.local. Co-authored-by: anton-vlasenko Co-authored-by: getdave Co-authored-by: hellofromtonya Co-authored-by: matiasbenedetto Co-authored-by: pbking --- .../fonts/class-wp-font-collection.php | 33 +++++++------- .../fonts/class-wp-font-library.php | 9 ++-- .../fonts/class-wp-font-utils.php | 35 +++++++-------- lib/compat/wordpress-6.5/fonts/fonts.php | 45 ++++++++++++++----- 4 files changed, 69 insertions(+), 53 deletions(-) diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php b/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php index c48d28a6f01fb6..154621ead50fbe 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-font-collection.php @@ -36,7 +36,7 @@ final class WP_Font_Collection { private $data; /** - * Font collection JSON file path or url. + * Font collection JSON file path or URL. * * @since 6.5.0 * @@ -51,12 +51,12 @@ final class WP_Font_Collection { * * @param string $slug Font collection slug. * @param array|string $data_or_file { - * Font collection data array or a file path or url to a JSON file containing the font collection. + * Font collection data array or a file path or URL to a JSON file containing the font collection. * - * @type string $name Name of the font collection. - * @type string $description Description of the font collection. - * @type array $font_families Array of font family definitions that are in the collection. - * @type array $categories Array of categories for the fonts that are in the collection. + * @type string $name Name of the font collection. + * @type string $description Description of the font collection. + * @type array $font_families Array of font family definitions included in the collection. + * @type array $categories Array of categories associated with the fonts in the collection. * } */ public function __construct( $slug, $data_or_file ) { @@ -104,23 +104,21 @@ public function get_data() { } // Set defaults for optional properties. - $data = wp_parse_args( + return wp_parse_args( $data, array( 'description' => '', 'categories' => array(), ) ); - - return $data; } /** - * Loads the font collection data from a JSON file path or url. + * Loads font collection data from a JSON file or URL. * * @since 6.5.0 * - * @param string $file_or_url File path or url to a JSON file containing the font collection data. + * @param string $file_or_url File path or URL to a JSON file containing the font collection data. * @return array|WP_Error An array containing the font collection data on success, * else an instance of WP_Error on failure. */ @@ -129,7 +127,7 @@ private function load_from_json( $file_or_url ) { $file = file_exists( $file_or_url ) ? wp_normalize_path( realpath( $file_or_url ) ) : false; if ( ! $url && ! $file ) { - // translators: %s: File path or url to font collection JSON file. + // translators: %s: File path or URL to font collection JSON file. $message = __( 'Font collection JSON file is invalid or does not exist.', 'gutenberg' ); _doing_it_wrong( __METHOD__, $message, '6.5.0' ); return new WP_Error( 'font_collection_json_missing', $message ); @@ -157,23 +155,23 @@ private function load_from_file( $file ) { } /** - * Loads the font collection data from a JSON file url. + * Loads the font collection data from a JSON file URL. * * @since 6.5.0 * - * @param string $url Url to a JSON file containing the font collection data. + * @param string $url URL to a JSON file containing the font collection data. * @return array|WP_Error An array containing the font collection data on success, * else an instance of WP_Error on failure. */ private function load_from_url( $url ) { - // Limit key to 167 characters to avoid failure in the case of a long url. + // Limit key to 167 characters to avoid failure in the case of a long URL. $transient_key = substr( 'wp_font_collection_url_' . $url, 0, 167 ); $data = get_site_transient( $transient_key ); if ( false === $data ) { $response = wp_safe_remote_get( $url ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { - // translators: %s: Font collection url. + // translators: %s: Font collection URL. return new WP_Error( 'font_collection_request_error', sprintf( __( 'Error fetching the font collection data from "%s".', 'gutenberg' ), $url ) ); } @@ -199,7 +197,7 @@ private function load_from_url( $url ) { * * @since 6.5.0 * - * @param array $data Font collection configuration. + * @param array $data Font collection configuration to validate. * @return array|WP_Error Array of data if valid, otherwise a WP_Error instance. */ private function validate_data( $data ) { @@ -216,6 +214,7 @@ private function validate_data( $data ) { return new WP_Error( 'font_collection_missing_property', $message ); } } + return $data; } } diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php b/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php index a33aead5571ec0..d5db42c499814c 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-font-library.php @@ -86,7 +86,7 @@ public static function register_font_collection( $slug, $data_or_file ) { * * @since 6.5.0 * - * @param string $collection_slug Font collection slug. + * @param string $slug Font collection slug. * @return bool True if the font collection was unregistered successfully and false otherwise. */ public static function unregister_font_collection( $slug ) { @@ -94,7 +94,7 @@ public static function unregister_font_collection( $slug ) { _doing_it_wrong( __METHOD__, /* translators: %s: Font collection slug. */ - sprintf( __( 'Font collection "%s" not found.', 'default' ), $slug ), + sprintf( __( 'Font collection "%s" not found.' ), $slug ), '6.5.0' ); return false; @@ -132,7 +132,8 @@ public static function get_font_collections() { * @since 6.5.0 * * @param string $slug Font collection slug. - * @return WP_Font_Collection Font collection object. + * @return WP_Font_Collection|WP_Error Font collection object, + * or WP_Error object if the font collection doesn't exist. */ public static function get_font_collection( $slug ) { if ( array_key_exists( $slug, self::$collections ) ) { @@ -141,8 +142,6 @@ public static function get_font_collection( $slug ) { return new WP_Error( 'font_collection_not_found', 'Font collection not found.' ); } - - /** * Sets the allowed mime types for fonts. * diff --git a/lib/compat/wordpress-6.5/fonts/class-wp-font-utils.php b/lib/compat/wordpress-6.5/fonts/class-wp-font-utils.php index 72a362a3a42a4d..792a5aaa80eef6 100644 --- a/lib/compat/wordpress-6.5/fonts/class-wp-font-utils.php +++ b/lib/compat/wordpress-6.5/fonts/class-wp-font-utils.php @@ -2,7 +2,7 @@ /** * Font Utils class. * - * This file contains utils related to the Font Library. + * Provides utility functions for working with font families. * * @package WordPress * @subpackage Font Library @@ -21,14 +21,15 @@ */ class WP_Font_Utils { /** - * Format font family names with surrounding quotes when the name contains a space. + * Format font family names. + * + * Adds surrounding quotes to font family names containing spaces and not already quoted. * * @since 6.5.0 * @access private * - * @param string $font_family Font family attribute. - * - * @return string The formatted font family attribute. + * @param string $font_family Font family name(s), comma-separated. + * @return string Formatted font family name(s). */ public static function format_font_family( $font_family ) { if ( $font_family ) { @@ -76,23 +77,18 @@ function ( $family ) { * @type string $fontStretch Optional font stretch, defaults to '100%'. * @type string $unicodeRange Optional unicode range, defaults to 'U+0-10FFFF'. * } - * * @return string Font face slug. */ public static function get_font_face_slug( $settings ) { - $settings = wp_parse_args( - $settings, - array( - 'fontFamily' => '', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'fontStretch' => '100%', - 'unicodeRange' => 'U+0-10FFFF', - ) + $defaults = array( + 'fontFamily' => '', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'fontStretch' => '100%', + 'unicodeRange' => 'U+0-10FFFF', ); + $settings = wp_parse_args( $settings, $defaults ); - // Convert all values to lowercase for comparison. - // Font family names may use multibyte characters. $font_family = mb_strtolower( $settings['fontFamily'] ); $font_style = strtolower( $settings['fontStyle'] ); $font_weight = strtolower( $settings['fontWeight'] ); @@ -100,8 +96,7 @@ public static function get_font_face_slug( $settings ) { $unicode_range = strtoupper( $settings['unicodeRange'] ); // Convert weight keywords to numeric strings. - $font_weight = str_replace( 'normal', '400', $font_weight ); - $font_weight = str_replace( 'bold', '700', $font_weight ); + $font_weight = str_replace( array( 'normal', 'bold' ), array( '400', '700' ), $font_weight ); // Convert stretch keywords to numeric strings. $font_stretch_map = array( @@ -137,7 +132,7 @@ function ( $elem ) { } /** - * Sanitize a tree of data using an schema that defines the sanitization to apply to each key. + * Sanitize a tree of data using a schema that defines the sanitization to apply to each key. * * It removes the keys not in the schema and applies the sanitizer to the values. * diff --git a/lib/compat/wordpress-6.5/fonts/fonts.php b/lib/compat/wordpress-6.5/fonts/fonts.php index 339ab32646d8b4..28b019b9c1fa61 100644 --- a/lib/compat/wordpress-6.5/fonts/fonts.php +++ b/lib/compat/wordpress-6.5/fonts/fonts.php @@ -19,7 +19,7 @@ * * @since 6.5.0 */ -function gutenberg_init_font_library_routes() { +function gutenberg_create_initial_post_types() { // @core-merge: This code will go into Core's `create_initial_post_types()`. $args = array( 'labels' => array( @@ -82,13 +82,30 @@ function gutenberg_init_font_library_routes() { 'autosave_rest_controller_class' => 'stdClass', ) ); +} +/** + * Initializes REST routes. + * + * @since 6.5 + */ +function gutenberg_create_initial_rest_routes() { // @core-merge: This code will go into Core's `create_initial_rest_routes()`. $font_collections_controller = new WP_REST_Font_Collections_Controller(); $font_collections_controller->register_routes(); } -add_action( 'rest_api_init', 'gutenberg_init_font_library_routes' ); +/** + * Initializes REST routes and post types. + * + * @since 6.5 + */ +function gutenberg_init_font_library() { + gutenberg_create_initial_post_types(); + gutenberg_create_initial_rest_routes(); +} + +add_action( 'rest_api_init', 'gutenberg_init_font_library' ); if ( ! function_exists( 'wp_register_font_collection' ) ) { @@ -108,7 +125,7 @@ function gutenberg_init_font_library_routes() { * @type array $categories Array of categories for the fonts that are in the collection. * } * @return WP_Font_Collection|WP_Error A font collection is it was registered - * successfully, else WP_Error. + * successfully, or WP_Error object on failure. */ function wp_register_font_collection( $slug, $data_or_file ) { return WP_Font_Library::register_font_collection( $slug, $data_or_file ); @@ -122,9 +139,10 @@ function wp_register_font_collection( $slug, $data_or_file ) { * @since 6.5.0 * * @param string $collection_id The font collection ID. + * @return bool True if the font collection was unregistered successfully, else false. */ function wp_unregister_font_collection( $collection_id ) { - WP_Font_Library::unregister_font_collection( $collection_id ); + return WP_Font_Library::unregister_font_collection( $collection_id ); } } @@ -151,7 +169,6 @@ function gutenberg_register_font_collections() { * @type string $baseurl URL path without subdir. * @type string|false $error False or error message. * } - * * @return array $defaults { * Array of information about the upload directory. * @@ -178,7 +195,15 @@ function wp_get_font_dir( $defaults = array() ) { $defaults['baseurl'] = untrailingslashit( content_url( 'fonts' ) ) . $site_path; $defaults['error'] = false; - // Filters the fonts directory data. + /** + * Filters the fonts directory data. + * + * This filter allows developers to modify the fonts directory data. + * + * @since 6.5.0 + * + * @param array $defaults The original fonts directory data. + */ return apply_filters( 'font_dir', $defaults ); } } @@ -194,7 +219,6 @@ function wp_get_font_dir( $defaults = array() ) { * * @param int $post_id Post ID. * @param WP_Post $post Post object. - * @return void */ function _wp_after_delete_font_family( $post_id, $post ) { if ( 'wp_font_family' !== $post->post_type ) { @@ -224,7 +248,6 @@ function _wp_after_delete_font_family( $post_id, $post ) { * * @param int $post_id Post ID. * @param WP_Post $post Post object. - * @return void */ function _wp_before_delete_font_face( $post_id, $post ) { if ( 'wp_font_face' !== $post->post_type ) { @@ -274,7 +297,7 @@ function gutenberg_convert_legacy_font_family_format() { continue; } - $font_faces = $font_family_json['fontFace'] ?? array(); + $font_faces = isset( $font_family_json['fontFace'] ) ? $font_family_json['fontFace'] : array(); unset( $font_family_json['fontFace'] ); // Save wp_font_face posts within the family. @@ -289,7 +312,7 @@ function gutenberg_convert_legacy_font_family_format() { $font_face_id = wp_insert_post( wp_slash( $args ) ); - $file_urls = (array) $font_face['src'] ?? array(); + $file_urls = (array) ( isset( $font_face['src'] ) ? $font_face['src'] : array() ); foreach ( $file_urls as $file_url ) { // continue if the file is not local. @@ -305,7 +328,7 @@ function gutenberg_convert_legacy_font_family_format() { // Update the font family post to remove the font face data. $args = array(); $args['ID'] = $font_family->ID; - $args['post_title'] = $font_family_json['name'] ?? ''; + $args['post_title'] = isset( $font_family_json['name'] ) ? $font_family_json['name'] : ''; $args['post_name'] = sanitize_title( $font_family_json['slug'] ); unset( $font_family_json['name'] ); From f38eb429b8ba5153c50fabad3367f94c3289746d Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Sat, 3 Feb 2024 21:12:16 +0100 Subject: [PATCH 02/31] Fix flaky test of data-wp-on-window directive (#58642) --- .../interactivity/directive-on-window.spec.ts | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/test/e2e/specs/interactivity/directive-on-window.spec.ts b/test/e2e/specs/interactivity/directive-on-window.spec.ts index dd3a02e0bb4009..3afd1a9942fa8e 100644 --- a/test/e2e/specs/interactivity/directive-on-window.spec.ts +++ b/test/e2e/specs/interactivity/directive-on-window.spec.ts @@ -18,38 +18,50 @@ test.describe( 'data-wp-on-window', () => { await utils.deleteAllPosts(); } ); - test( 'callbacks should run whenever the specified event is dispatched', async ( { - page, - } ) => { - await page.setViewportSize( { width: 600, height: 600 } ); - const counter = page.getByTestId( 'counter' ); - await expect( counter ).toHaveText( '1' ); - } ); - test( 'the event listener is removed when the element is removed', async ( { + test( 'the event listener is attached when the element is added', async ( { page, } ) => { const counter = page.getByTestId( 'counter' ); - const isEventAttached = page.getByTestId( 'isEventAttached' ); const visibilityButton = page.getByTestId( 'visibility' ); + // Initial value. await expect( counter ).toHaveText( '0' ); - await expect( isEventAttached ).toHaveText( 'yes' ); + + // Make sure the event listener is attached. + await page + .getByTestId( 'isEventAttached' ) + .filter( { hasText: 'yes' } ) + .waitFor(); + + // Change the viewport size. await page.setViewportSize( { width: 600, height: 600 } ); await expect( counter ).toHaveText( '1' ); // Remove the element. await visibilityButton.click(); + // Make sure the event listener is not attached. + await page + .getByTestId( 'isEventAttached' ) + .filter( { hasText: 'no' } ) + .waitFor(); + // This resize should not increase the counter. await page.setViewportSize( { width: 300, height: 600 } ); // Add the element back. await visibilityButton.click(); + + // The counter should have the same value as before. await expect( counter ).toHaveText( '1' ); - // Wait until the effects run again. - await expect( isEventAttached ).toHaveText( 'yes' ); + // Make sure the event listener is re-attached. + await page + .getByTestId( 'isEventAttached' ) + .filter( { hasText: 'yes' } ) + .waitFor(); + // This resize should increase the counter. await page.setViewportSize( { width: 200, height: 600 } ); await expect( counter ).toHaveText( '2' ); } ); From 19b2bf9cc51074e3bcc9448689618912c391ab5d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Sun, 4 Feb 2024 15:47:51 +0100 Subject: [PATCH 03/31] Interactivity Router: Fix initial page cache (#58496) * Create `initialVdom` * Expose private APIs with a simple funciton * Fix typescript * Use `initialVdom` to cache the first page. * Fix the Query block when navigating * Update the required consent text Co-authored-by: Luis Herranz * Change the error text Co-authored-by: Luis Herranz * Update consent text when calling privateApis Co-authored-by: Luis Herranz --------- Co-authored-by: Luis Herranz --- packages/block-library/src/query/view.js | 13 ++++++------ packages/interactivity-router/src/index.js | 23 +++++++++++----------- packages/interactivity/src/index.js | 23 ++++++++++++++++++---- packages/interactivity/src/init.js | 4 ++++ packages/interactivity/src/vdom.js | 7 ++++++- 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index dc82d7968dad45..ee811b4b8e90f1 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -23,7 +23,9 @@ store( 'core/query', { *navigate( event ) { const ctx = getContext(); const { ref } = getElement(); - const { queryRef } = ctx; + const queryRef = ref.closest( + '.wp-block-query[data-wp-router-region]' + ); const isDisabled = queryRef?.dataset.wpNavigationDisabled; if ( isValidLink( ref ) && isValidEvent( event ) && ! isDisabled ) { @@ -41,8 +43,10 @@ store( 'core/query', { } }, *prefetch() { - const { queryRef } = getContext(); const { ref } = getElement(); + const queryRef = ref.closest( + '.wp-block-query[data-wp-router-region]' + ); const isDisabled = queryRef?.dataset.wpNavigationDisabled; if ( isValidLink( ref ) && ! isDisabled ) { const { actions } = yield import( @@ -63,10 +67,5 @@ store( 'core/query', { yield actions.prefetch( ref.href ); } }, - setQueryRef() { - const ctx = getContext(); - const { ref } = getElement(); - ctx.queryRef = ref; - }, }, } ); diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js index a985ed3d74b896..46f184b4ec030e 100644 --- a/packages/interactivity-router/src/index.js +++ b/packages/interactivity-router/src/index.js @@ -1,13 +1,12 @@ /** * WordPress dependencies */ -import { - render, - directivePrefix, - toVdom, - getRegionRootFragment, - store, -} from '@wordpress/interactivity'; +import { render, store, privateApis } from '@wordpress/interactivity'; + +const { directivePrefix, getRegionRootFragment, initialVdom, toVdom } = + privateApis( + 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.' + ); // The cache of visited and prefetched pages. const pages = new Map(); @@ -36,12 +35,14 @@ const fetchPage = async ( url, { html } ) => { // Return an object with VDOM trees of those HTML regions marked with a // `router-region` directive. -const regionsToVdom = ( dom ) => { +const regionsToVdom = ( dom, { vdom } = {} ) => { const regions = {}; const attrName = `data-${ directivePrefix }-router-region`; dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => { const id = region.getAttribute( attrName ); - regions[ id ] = toVdom( region ); + regions[ id ] = vdom?.has( region ) + ? vdom.get( region ) + : toVdom( region ); } ); const title = dom.querySelector( 'title' )?.innerText; return { regions, title }; @@ -74,10 +75,10 @@ window.addEventListener( 'popstate', async () => { } } ); -// Cache the current regions. +// Cache the initial page using the intially parsed vDOM. pages.set( getPagePath( window.location ), - Promise.resolve( regionsToVdom( document ) ) + Promise.resolve( regionsToVdom( document, { vdom: initialVdom } ) ) ); // Variable to store the current navigation. diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js index 5d9165dc9920ee..477b90db1efc1f 100644 --- a/packages/interactivity/src/index.js +++ b/packages/interactivity/src/index.js @@ -2,7 +2,9 @@ * Internal dependencies */ import registerDirectives from './directives'; -import { init } from './init'; +import { init, getRegionRootFragment, initialVdom } from './init'; +import { directivePrefix } from './constants'; +import { toVdom } from './vdom'; export { store } from './store'; export { directive, getContext, getElement, getNamespace } from './hooks'; @@ -15,14 +17,27 @@ export { useCallback, useMemo, } from './utils'; -export { directivePrefix } from './constants'; -export { toVdom } from './vdom'; -export { getRegionRootFragment } from './init'; export { h as createElement, cloneElement, render } from 'preact'; export { useContext, useState, useRef } from 'preact/hooks'; export { deepSignal } from 'deepsignal'; +const requiredConsent = + 'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.'; + +export const privateApis = ( lock ) => { + if ( lock === requiredConsent ) { + return { + directivePrefix, + getRegionRootFragment, + initialVdom, + toVdom, + }; + } + + throw new Error( 'Forbidden access.' ); +}; + document.addEventListener( 'DOMContentLoaded', async () => { registerDirectives(); await init(); diff --git a/packages/interactivity/src/init.js b/packages/interactivity/src/init.js index 839302c4f8c6b2..fb510a9c00fced 100644 --- a/packages/interactivity/src/init.js +++ b/packages/interactivity/src/init.js @@ -28,6 +28,9 @@ function yieldToMain() { } ); } +// Initial vDOM regions associated with its DOM element. +export const initialVdom = new WeakMap(); + // Initialize the router with the initial DOM. export const init = async () => { const nodes = document.querySelectorAll( @@ -39,6 +42,7 @@ export const init = async () => { await yieldToMain(); const fragment = getRegionRootFragment( node ); const vdom = toVdom( node ); + initialVdom.set( node, vdom ); await yieldToMain(); hydrate( vdom, fragment ); } diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js index 4a7cfff9f9d0df..01cf58ba00479b 100644 --- a/packages/interactivity/src/vdom.js +++ b/packages/interactivity/src/vdom.js @@ -35,7 +35,12 @@ const nsPathRegExp = /^([\w-_\/]+)::(.+)$/; export const hydratedIslands = new WeakSet(); -// Recursive function that transforms a DOM tree into vDOM. +/** + * Recursive function that transforms a DOM tree into vDOM. + * + * @param {Node} root The root element or node to start traversing on. + * @return {import('preact').VNode[]} The resulting vDOM tree. + */ export function toVdom( root ) { const treeWalker = document.createTreeWalker( root, From 096d518bb306583abd293a1c8750e25289f5d6fc Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Sun, 4 Feb 2024 22:02:39 +0100 Subject: [PATCH 04/31] Interactivity API: Server Directive Processor for `data-wp-each` (#58498) * Add append_content_after_closing_tag_on_balanced_or_void_tags method * Make next_balanced_tag_closer_tag public and add tests * Add compat for array_is_list added in WP 6.5 * Add missing covers in wp-text tests * Minor fixes to the Interactivity API directive processor * Create internal method process_directives_args to call it from wp-each * Add kebab-case to camelCase method * Add wp-each processor * Make sure it doesn't process non-array values * Trim before checking that starts and ends with tags * Fix typo and some extra tabs * Fix PHPCS * Add kebal to camel case conversion in the JS runtime * Add extra nested test * Restrict appending to template tags * Override WP Core script modules * Switch to `data-wp-remove` directive * Rename back to data-wp-each-child * Add missing continue in foreach loop * Don't return a value * Transform interactivity index to TS * Fix includes_url logic * Also move vdom to TS Co-authored-by: luisherranz Co-authored-by: DAreRodz --- lib/compat/wordpress-6.5/compat.php | 38 ++ ...interactivity-api-directives-processor.php | 63 +- .../class-wp-interactivity-api.php | 198 +++++- lib/experimental/interactivity-api.php | 22 + lib/load.php | 2 + .../directive-each/render.php | 12 + packages/interactivity/src/directives.js | 4 +- .../interactivity/src/{index.js => index.ts} | 0 .../src/utils/kebab-to-camelcase.js | 14 + .../interactivity/src/utils/test/utils.js | 26 + .../interactivity/src/{vdom.js => vdom.ts} | 2 +- ...activity-api-directives-processor-test.php | 434 ++++++++++-- .../class-wp-interactivity-api-test.php | 22 + ...lass-wp-interactivity-api-wp-each-test.php | 636 ++++++++++++++++++ ...lass-wp-interactivity-api-wp-text-test.php | 14 + .../interactivity/directive-each.spec.ts | 9 + 16 files changed, 1407 insertions(+), 89 deletions(-) create mode 100644 lib/compat/wordpress-6.5/compat.php create mode 100644 lib/experimental/interactivity-api.php rename packages/interactivity/src/{index.js => index.ts} (100%) create mode 100644 packages/interactivity/src/utils/kebab-to-camelcase.js create mode 100644 packages/interactivity/src/utils/test/utils.js rename packages/interactivity/src/{vdom.js => vdom.ts} (98%) create mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php diff --git a/lib/compat/wordpress-6.5/compat.php b/lib/compat/wordpress-6.5/compat.php new file mode 100644 index 00000000000000..78447927125894 --- /dev/null +++ b/lib/compat/wordpress-6.5/compat.php @@ -0,0 +1,38 @@ + $arr The array being evaluated. + * @return bool True if array is a list, false otherwise. + */ + function array_is_list( $arr ) { + if ( ( array() === $arr ) || ( array_values( $arr ) === $arr ) ) { + return true; + } + + $next_key = -1; + + foreach ( $arr as $k => $v ) { + if ( ++$next_key !== $k ) { + return false; + } + } + + return true; + } +} diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php index b437bcefa67568..b4cfa5a499872c 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php @@ -15,14 +15,24 @@ */ class WP_Interactivity_API_Directives_Processor extends Gutenberg_HTML_Tag_Processor_6_5 { /** - * Returns the content between two balanced tags. + * Returns the content between two balanced template tags. + * + * It positions the cursor in the closer tag of the balanced template tag, + * if it exists. * * @access private * - * @return string|null The content between the current opening and its matching closing tag or null if it doesn't - * find the matching closing tag. + * @return string|null The content between the current opener template tag and its matching closer tag or null if it + * doesn't find the matching closing tag. */ - public function get_content_between_balanced_tags() { + public function get_content_between_balanced_template_tags() { + if ( 'TEMPLATE' !== $this->get_tag() || $this->is_tag_closer() ) { + return null; + } + + // Flushes any changes. + $this->get_updated_html(); + $bookmarks = $this->get_balanced_tag_bookmarks(); if ( ! $bookmarks ) { return null; @@ -32,7 +42,6 @@ public function get_content_between_balanced_tags() { $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; $end = $this->bookmarks[ $end_name ]->start; - $this->seek( $start_name ); $this->release_bookmark( $start_name ); $this->release_bookmark( $end_name ); @@ -48,6 +57,7 @@ public function get_content_between_balanced_tags() { * @return bool Whether the content was successfully replaced. */ public function set_content_between_balanced_tags( string $new_content ): bool { + // Flushes any changes. $this->get_updated_html(); $bookmarks = $this->get_balanced_tag_bookmarks(); @@ -67,6 +77,37 @@ public function set_content_between_balanced_tags( string $new_content ): bool { return true; } + /** + * Appends content after the closing tag of a template tag. + * + * This method positions the processor in the last tag of the appended + * content, if it exists. + * + * @access private + * + * @param string $new_content The string to append after the closing template tag. + * @return bool Whether the content was successfully appended. + */ + public function append_content_after_template_tag_closer( string $new_content ): bool { + // Refuses to process if the content is empty or this is not a closer template tag. + if ( empty( $new_content ) || 'TEMPLATE' !== $this->get_tag() || ! $this->is_tag_closer() ) { + return false; + } + + // Flushes any changes. + $this->get_updated_html(); + + $bookmark = 'append_content_after_template_tag_closer'; + $this->set_bookmark( $bookmark ); + $end = $this->bookmarks[ $bookmark ]->start + $this->bookmarks[ $bookmark ]->length + 1; + $this->release_bookmark( $bookmark ); + + // Appends the new content. + $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $end, 0, $new_content ); + + return true; + } + /** * Returns a pair of bookmarks for the current opening tag and the matching * closing tag. @@ -78,7 +119,7 @@ private function get_balanced_tag_bookmarks() { $start_name = 'start_of_balanced_tag_' . ++$i; $this->set_bookmark( $start_name ); - if ( ! $this->next_balanced_closer() ) { + if ( ! $this->next_balanced_tag_closer_tag() ) { $this->release_bookmark( $start_name ); return null; } @@ -93,13 +134,15 @@ private function get_balanced_tag_bookmarks() { * Finds the matching closing tag for an opening tag. * * When called while the processor is on an open tag, it traverses the HTML - * until it finds the matching closing tag, respecting any in-between content, - * including nested tags of the same name. Returns false when called on a - * closing or void tag, or if no matching closing tag was found. + * until it finds the matching closing tag, respecting any in-between + * content, including nested tags of the same name. Returns false when + * called on a closing or void tag, or if no matching closing tag was found. + * + * @access private * * @return bool Whether a matching closing tag was found. */ - private function next_balanced_closer(): bool { + public function next_balanced_tag_closer_tag(): bool { $depth = 0; $tag_name = $this->get_tag(); diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php index 9cbbfb1d6b6540..be9203198d3f2f 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php @@ -25,6 +25,12 @@ class WP_Interactivity_API { 'data-wp-class' => 'data_wp_class_processor', 'data-wp-style' => 'data_wp_style_processor', 'data-wp-text' => 'data_wp_text_processor', + /* + * `data-wp-each` needs to be processed in the last place because it moves + * the cursor to the end of the processed items to prevent them to be + * processed twice. + */ + 'data-wp-each' => 'data_wp_each_processor', ); /** @@ -194,11 +200,30 @@ public function add_hooks() { * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags. */ public function process_directives( string $html ): string { - $p = new WP_Interactivity_API_Directives_Processor( $html ); - $tag_stack = array(); - $namespace_stack = array(); $context_stack = array(); - $unbalanced = false; + $namespace_stack = array(); + $result = $this->process_directives_args( $html, $context_stack, $namespace_stack ); + return null === $result ? $html : $result; + } + + /** + * Processes the interactivity directives contained within the HTML content + * and updates the markup accordingly. + * + * It needs the context and namespace stacks to be passed by reference and + * it returns null if the HTML contains unbalanced tags. + * + * @since 6.5.0 + * + * @param string $html The HTML content to process. + * @param array $context_stack The reference to the array used to keep track of contexts during processing. + * @param array $namespace_stack The reference to the array used to manage namespaces during processing. + * @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags. + */ + private function process_directives_args( string $html, array &$context_stack, array &$namespace_stack ) { + $p = new WP_Interactivity_API_Directives_Processor( $html ); + $tag_stack = array(); + $unbalanced = false; $directive_processor_prefixes = array_keys( self::$directive_processors ); $directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes ); @@ -234,26 +259,28 @@ public function process_directives( string $html ): string { } } } else { - $directives_prefixes = array(); - - foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { + if ( 0 === count( $p->get_attribute_names_with_prefix( 'data-wp-each-child' ) ) ) { + $directives_prefixes = array(); + + // Checks if there is is a server directive processor registered for each directive. + foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { + list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { + $directives_prefixes[] = $directive_prefix; + } + } /* - * Extracts the directive prefix to see if there is a server directive - * processor registered for that directive. - */ - list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name ); - if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { - $directives_prefixes[] = $directive_prefix; + * If this is not a void element, it adds it to the tag stack so it can + * process its closing tag and check for unbalanced tags. + */ + if ( ! $p->is_void() ) { + $tag_stack[] = array( $tag_name, $directives_prefixes ); } - } - - /* - * If this is not a void element, it adds it to the tag stack so it can - * process its closing tag and check for unbalanced tags. - */ - if ( ! $p->is_void() ) { - $tag_stack[] = array( $tag_name, $directives_prefixes ); + } else { + // Jumps to the tag closer if the tag has a `data-wp-each-child` directive. + $p->next_balanced_tag_closer_tag(); + continue; } } @@ -276,17 +303,17 @@ public function process_directives( string $html ): string { : array( $this, self::$directive_processors[ $directive_prefix ] ); call_user_func_array( $func, - array( $p, &$context_stack, &$namespace_stack ) + array( $p, &$context_stack, &$namespace_stack, &$tag_stack ) ); } } /* - * It returns the original content if the HTML is unbalanced because - * unbalanced HTML is not safe to process. In that case, the Interactivity - * API runtime will update the HTML on the client side during the hydration. + * It returns null if the HTML is unbalanced because unbalanced HTML is + * not safe to process. In that case, the Interactivity API runtime will + * update the HTML on the client side during the hydration. */ - return $unbalanced || 0 < count( $tag_stack ) ? $html : $p->get_updated_html(); + return $unbalanced || 0 < count( $tag_stack ) ? null : $p->get_updated_html(); } /** @@ -403,6 +430,23 @@ private function extract_directive_value( $directive_value, $default_namespace = return array( $default_namespace, $directive_value ); } + /** + * Transforms a kebab-case string to camelCase. + * + * @param string $str The kebab-case string to transform to camelCase. + * @return string The transformed camelCase string. + */ + private function kebab_to_camel_case( string $str ): string { + return lcfirst( + preg_replace_callback( + '/(-)([a-z])/', + function ( $matches ) { + return strtoupper( $matches[2] ); + }, + strtolower( preg_replace( '/-+$/', '', $str ) ) + ) + ); + } /** * Processes the `data-wp-interactive` directive. @@ -768,9 +812,109 @@ class="screen-reader-text" > HTML; }; + add_action( 'wp_footer', $callback ); } } - } + /** + * Processes the `data-wp-each` directive. + * + * This directive gets an array passed as reference and iterates over it + * generating new content for each item based on the inner markup of the + * `template` tag. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + * @param array $tag_stack The reference to the tag stack. + */ + private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack, array &$tag_stack ) { + if ( ! $p->is_tag_closer() && 'TEMPLATE' === $p->get_tag() ) { + $attribute_name = $p->get_attribute_names_with_prefix( 'data-wp-each' )[0]; + $extracted_suffix = $this->extract_prefix_and_suffix( $attribute_name ); + $item_name = isset( $extracted_suffix[1] ) ? $this->kebab_to_camel_case( $extracted_suffix[1] ) : 'item'; + $attribute_value = $p->get_attribute( $attribute_name ); + $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) ); + + // Gets the content between the template tags and leaves the cursor in the closer tag. + $inner_content = $p->get_content_between_balanced_template_tags(); + + // Checks if there is a manual server-side directive processing. + $template_end = 'data-wp-each: template end'; + $p->set_bookmark( $template_end ); + $p->next_tag(); + $manual_sdp = $p->get_attribute( 'data-wp-each-child' ); + $p->seek( $template_end ); // Rewinds to the template closer tag. + $p->release_bookmark( $template_end ); + + /* + * It doesn't process in these situations: + * - Manual server-side directive processing. + * - Empty or non-array values. + * - Associative arrays because those are deserialized as objects in JS. + * - Templates that contain top-level texts because those texts can't be + * identified and removed in the client. + */ + if ( + $manual_sdp || + empty( $result ) || + ! is_array( $result ) || + ! array_is_list( $result ) || + ! str_starts_with( trim( $inner_content ), '<' ) || + ! str_ends_with( trim( $inner_content ), '>' ) + ) { + array_pop( $tag_stack ); + return; + } + + // Extracts the namespace from the directive attribute value. + $namespace_value = end( $namespace_stack ); + list( $namespace_value ) = is_string( $attribute_value ) && ! empty( $attribute_value ) + ? $this->extract_directive_value( $attribute_value, $namespace_value ) + : array( $namespace_value, null ); + + // Processes the inner content for each item of the array. + $processed_content = ''; + foreach ( $result as $item ) { + // Creates a new context that includes the current item of the array. + array_push( + $context_stack, + array_replace_recursive( + end( $context_stack ) !== false ? end( $context_stack ) : array(), + array( $namespace_value => array( $item_name => $item ) ) + ) + ); + + // Processes the inner content with the new context. + $processed_item = $this->process_directives_args( $inner_content, $context_stack, $namespace_stack ); + + if ( null === $processed_item ) { + // If the HTML is unbalanced, stop processing it. + array_pop( $context_stack ); + return; + } + + // Adds the `data-wp-each-child` to each top-level tag. + $i = new WP_Interactivity_API_Directives_Processor( $processed_item ); + while ( $i->next_tag() ) { + $i->set_attribute( 'data-wp-each-child', true ); + $i->next_balanced_tag_closer_tag(); + } + $processed_content .= $i->get_updated_html(); + + // Removes the current context from the stack. + array_pop( $context_stack ); + } + + // Appends the processed content after the tag closer of the template. + $p->append_content_after_template_tag_closer( $processed_content ); + + // Pops the last tag because it skipped the closing tag of the template tag. + array_pop( $tag_stack ); + } + } + } } diff --git a/lib/experimental/interactivity-api.php b/lib/experimental/interactivity-api.php new file mode 100644 index 00000000000000..aff57bf0bce807 --- /dev/null +++ b/lib/experimental/interactivity-api.php @@ -0,0 +1,22 @@ + +
+ + +

A

+

B

+

C

+
+ +
+