diff --git a/Gruntfile.js b/Gruntfile.js index 355a8989db3db..d196c51152658 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -612,6 +612,17 @@ module.exports = function(grunt) { dest: WORKING_DIR + 'wp-includes/build/', } ], }, + 'gutenberg-js': { + files: [ { + expand: true, + cwd: 'gutenberg/build', + src: [ + 'pages/**/*.js', + 'routes/**/*.js', + ], + dest: WORKING_DIR + 'wp-includes/build/', + } ], + }, 'gutenberg-modules': { files: [ { expand: true, @@ -2041,6 +2052,7 @@ module.exports = function(grunt) { grunt.registerTask( 'build:gutenberg', [ 'copy:gutenberg-php', + 'copy:gutenberg-js', 'gutenberg:copy', 'copy:gutenberg-modules', 'copy:gutenberg-styles', diff --git a/package.json b/package.json index 117cf46f5455f..5a390aac47174 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "sha": "9b8144036fa5faf75de43d4502ff9809fcf689ad", + "sha": "8c78d87453509661a9f28f978ba2c242d515563b", "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" }, "engines": { diff --git a/src/js/_enqueues/wp/util.js b/src/js/_enqueues/wp/util.js index c603bc69f4a21..f6425f2178bcb 100644 --- a/src/js/_enqueues/wp/util.js +++ b/src/js/_enqueues/wp/util.js @@ -36,10 +36,11 @@ window.wp = window.wp || {}; }; return function ( data ) { - if ( ! document.getElementById( 'tmpl-' + id ) ) { + var el = document.querySelector( 'script#tmpl-' + id ); + if ( ! el ) { throw new Error( 'Template not found: ' + '#tmpl-' + id ); } - compiled = compiled || _.template( $( '#tmpl-' + id ).html(), options ); + compiled = compiled || _.template( $( el ).html(), options ); return compiled( data ); }; }); diff --git a/src/wp-admin/admin-header.php b/src/wp-admin/admin-header.php index e1e9ba0f6562b..005edc227e06a 100644 --- a/src/wp-admin/admin-header.php +++ b/src/wp-admin/admin-header.php @@ -191,7 +191,11 @@ $admin_body_class .= ' taxonomy-' . $current_screen->taxonomy; } -$admin_body_class .= ' branch-' . str_replace( array( '.', ',' ), '-', (float) get_bloginfo( 'version' ) ); +$major_version = wp_get_wp_version( 'major' ); +$admin_body_class .= ' branch-' . str_replace( '.', '-', $major_version ); +if ( str_ends_with( $major_version, '.0' ) ) { + $admin_body_class .= ' branch-' . (int) $major_version; +} $admin_body_class .= ' version-' . str_replace( '.', '-', preg_replace( '/^([.0-9]+).*/', '$1', get_bloginfo( 'version' ) ) ); $admin_body_class .= ' admin-color-' . sanitize_html_class( get_user_option( 'admin_color' ), 'modern' ); $admin_body_class .= ' locale-' . sanitize_html_class( strtolower( str_replace( '_', '-', get_user_locale() ) ) ); diff --git a/src/wp-admin/admin.php b/src/wp-admin/admin.php index 1186f9bedce21..82ab6b93ac99e 100644 --- a/src/wp-admin/admin.php +++ b/src/wp-admin/admin.php @@ -395,17 +395,22 @@ */ if ( 'page' === $typenow ) { if ( 'post-new.php' === $pagenow ) { + /** This action is documented in wp-admin/admin.php */ do_action( 'load-page-new.php' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores } elseif ( 'post.php' === $pagenow ) { + /** This action is documented in wp-admin/admin.php */ do_action( 'load-page.php' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores } } elseif ( 'edit-tags.php' === $pagenow ) { if ( 'category' === $taxnow ) { + /** This action is documented in wp-admin/admin.php */ do_action( 'load-categories.php' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores } elseif ( 'link_category' === $taxnow ) { + /** This action is documented in wp-admin/admin.php */ do_action( 'load-edit-link-categories.php' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores } } elseif ( 'term.php' === $pagenow ) { + /** This action is documented in wp-admin/admin.php */ do_action( 'load-edit-tags.php' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores } } diff --git a/src/wp-admin/css/colors/_admin.scss b/src/wp-admin/css/colors/_admin.scss index 5d9bcb3f7bb89..011912fa7efeb 100644 --- a/src/wp-admin/css/colors/_admin.scss +++ b/src/wp-admin/css/colors/_admin.scss @@ -88,27 +88,12 @@ input[type="checkbox"]:checked { border-color: var(--wp-admin-theme-color); } -input[type=checkbox]:checked::before { - content: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2020%2020%27%3E%3Cpath%20d%3D%27M14.83%204.89l1.34.94-5.81%208.38H9.02L5.78%209.67l1.34-1.25%202.57%202.4z%27%20fill%3D%27#{url-friendly-colour(variables.$form-checked)}%27%2F%3E%3C%2Fsvg%3E"); - content: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2020%2020%27%3E%3Cpath%20d%3D%27M14.83%204.89l1.34.94-5.81%208.38H9.02L5.78%209.67l1.34-1.25%202.57%202.4z%27%20fill%3D%27#{url-friendly-colour(variables.$form-checked)}%27%2F%3E%3C%2Fsvg%3E") / ''; -} - -// Checkbox checkmark - white for visibility on theme color background -input[type="checkbox"]:checked::before { - content: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2020%2020%27%3E%3Cpath%20d%3D%27M14.83%204.89l1.34.94-5.81%208.38H9.02L5.78%209.67l1.34-1.25%202.57%202.4z%27%20fill%3D%27%23ffffff%27%2F%3E%3C%2Fsvg%3E") / ''; -} - // Radio checked state - uses theme color input[type="radio"]:checked { background: var(--wp-admin-theme-color); border-color: var(--wp-admin-theme-color); } -// Radio dot - white for visibility on theme color background -input[type="radio"]:checked::before { - background: tokens.$white; -} - .wp-core-ui input[type="reset"]:hover, .wp-core-ui input[type="reset"]:active { color: variables.$link-focus; diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index 13b9234e8375d..b88045c8d2d53 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -1380,6 +1380,12 @@ th.action-links { } } +@media only screen and (max-width: 1250px) { + .wp-filter:has(.plugin-install-search) .search-form { + margin: 11px 0; + } +} + @media only screen and (max-width: 1120px) { .filter-drawer { border-bottom: 1px solid #f0f0f1; @@ -2263,8 +2269,9 @@ html.wp-toolbar { color: #a7aaad; } -.sortable-placeholder { +.sortable-placeholder:not(.empty-container .sortable-placeholder) { border: 1px dashed #c3c4c7; + border-radius: 8px; margin-bottom: 20px; } diff --git a/src/wp-admin/css/dashboard.css b/src/wp-admin/css/dashboard.css index 74fcfbcae3c55..a260f4d60e6ee 100644 --- a/src/wp-admin/css/dashboard.css +++ b/src/wp-admin/css/dashboard.css @@ -83,7 +83,6 @@ } .is-dragging-metaboxes #dashboard-widgets .postbox-container .empty-container { - outline: 2px dashed rgb(0, 0, 0, 0.15); background: rgb(0, 0, 0, 0.01); } diff --git a/src/wp-admin/css/install.css b/src/wp-admin/css/install.css index 104941e556616..4173e9a228fda 100644 --- a/src/wp-admin/css/install.css +++ b/src/wp-admin/css/install.css @@ -79,8 +79,8 @@ fieldset { width: 84px; height: 84px; overflow: hidden; - background-image: url(../images/w-logo-blue.png?ver=20131202); - background-image: none, url(../images/wordpress-logo.svg?ver=20131107); + background-image: url(../images/w-logo-gray.png?ver=20260303); + background-image: none, url(../images/wordpress-logo-gray.svg?ver=20260303); background-size: 84px; background-position: center top; background-repeat: no-repeat; diff --git a/src/wp-admin/css/login.css b/src/wp-admin/css/login.css index ca49a0128c1ba..3d2809eb2b625 100644 --- a/src/wp-admin/css/login.css +++ b/src/wp-admin/css/login.css @@ -283,8 +283,8 @@ p { } .login h1 a { - background-image: url(../images/w-logo-blue.png?ver=20131202); - background-image: none, url(../images/wordpress-logo.svg?ver=20131107); + background-image: url(../images/w-logo-gray.png?ver=20260303); + background-image: none, url(../images/wordpress-logo-gray.svg?ver=20260303); background-size: 84px; background-position: center top; background-repeat: no-repeat; diff --git a/src/wp-admin/images/w-logo-gray.png b/src/wp-admin/images/w-logo-gray.png new file mode 100644 index 0000000000000..435486e7ed70d Binary files /dev/null and b/src/wp-admin/images/w-logo-gray.png differ diff --git a/src/wp-admin/images/wordpress-logo-gray.svg b/src/wp-admin/images/wordpress-logo-gray.svg new file mode 100644 index 0000000000000..e8fbd8cd95f50 --- /dev/null +++ b/src/wp-admin/images/wordpress-logo-gray.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index f52f5f5c1d80f..2af08fba70af9 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -2382,7 +2382,7 @@ function wp_ajax_save_widget() { */ do_action( 'widgets.php' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores - /** This action is documented in wp-admin/widgets.php */ + /** This action is documented in wp-admin/widgets-form.php */ do_action( 'sidebar_admin_setup' ); $id_base = wp_unslash( $_POST['id_base'] ); @@ -2410,7 +2410,7 @@ function wp_ajax_save_widget() { 'delete_widget' => '1', ); - /** This action is documented in wp-admin/widgets.php */ + /** This action is documented in wp-admin/widgets-form.php */ do_action( 'delete_widget', $widget_id, $sidebar_id, $id_base ); } elseif ( $settings && preg_match( '/__i__|%i%/', key( $settings ) ) ) { @@ -2486,7 +2486,7 @@ function wp_ajax_delete_inactive_widgets() { do_action( 'load-widgets.php' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores /** This action is documented in wp-admin/includes/ajax-actions.php */ do_action( 'widgets.php' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores - /** This action is documented in wp-admin/widgets.php */ + /** This action is documented in wp-admin/widgets-form.php */ do_action( 'sidebar_admin_setup' ); $sidebars_widgets = wp_get_sidebars_widgets(); diff --git a/src/wp-admin/includes/class-core-upgrader.php b/src/wp-admin/includes/class-core-upgrader.php index 8a712a4afbc76..ef1336a40bda7 100644 --- a/src/wp-admin/includes/class-core-upgrader.php +++ b/src/wp-admin/includes/class-core-upgrader.php @@ -279,8 +279,8 @@ public function upgrade( $current, $args = array() ) { public static function should_update_to_version( $offered_ver ) { require ABSPATH . WPINC . '/version.php'; // $wp_version; // x.y.z - $current_branch = implode( '.', array_slice( preg_split( '/[.-]/', $wp_version ), 0, 2 ) ); // x.y - $new_branch = implode( '.', array_slice( preg_split( '/[.-]/', $offered_ver ), 0, 2 ) ); // x.y + $current_branch = wp_get_branch_version( $wp_version ); // x.y + $new_branch = wp_get_branch_version( $offered_ver ); // x.y $current_is_development_version = (bool) strpos( $wp_version, '-' ); diff --git a/src/wp-admin/includes/class-walker-nav-menu-checklist.php b/src/wp-admin/includes/class-walker-nav-menu-checklist.php index b4e4f1a48049a..f720a4768e09a 100644 --- a/src/wp-admin/includes/class-walker-nav-menu-checklist.php +++ b/src/wp-admin/includes/class-walker-nav-menu-checklist.php @@ -116,11 +116,11 @@ public function start_el( &$output, $data_object, $depth = 0, $args = null, $cur $output .= ''; $output .= ''; $output .= ''; - $output .= ''; + $output .= ''; $output .= ''; $output .= ''; - $output .= ''; - $output .= ''; - $output .= ''; + $output .= ''; + $output .= ''; + $output .= ''; } } diff --git a/src/wp-admin/includes/class-walker-nav-menu-edit.php b/src/wp-admin/includes/class-walker-nav-menu-edit.php index 44a57c0c99f26..a287a7c4b8cdd 100644 --- a/src/wp-admin/includes/class-walker-nav-menu-edit.php +++ b/src/wp-admin/includes/class-walker-nav-menu-edit.php @@ -203,13 +203,13 @@ public function start_el( &$output, $data_object, $depth = 0, $args = null, $cur

diff --git a/src/wp-admin/includes/class-wp-site-health-auto-updates.php b/src/wp-admin/includes/class-wp-site-health-auto-updates.php index 1904acd4e08c5..ce4341a785e86 100644 --- a/src/wp-admin/includes/class-wp-site-health-auto-updates.php +++ b/src/wp-admin/includes/class-wp-site-health-auto-updates.php @@ -352,7 +352,17 @@ public function test_all_files_writable() { $dev = ( str_contains( $wp_version, '-' ) ); // Get the last stable version's files and test against that. if ( ! $checksums && $dev ) { - $checksums = get_core_checksums( (float) $wp_version - 0.1, 'en_US' ); + $parts = explode( '.', wp_get_branch_version( $wp_version ) ); + $major = (int) $parts[0]; + $minor = (int) $parts[1]; + + if ( $minor > 0 ) { + $prev_ver = $major . '.' . ( $minor - 1 ); + } else { + $prev_ver = ( $major - 1 ) . '.9'; + } + + $checksums = get_core_checksums( $prev_ver, 'en_US' ); } // There aren't always checksums for development releases, so just skip the test if we still can't find any. diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index 99dc03c6cd656..bea00005d9f60 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -1895,6 +1895,11 @@ function _unzip_file_pclzip( $file, $to, $needed_dirs = array() ) { continue; } + // Don't extract invalid files: + if ( 0 !== validate_file( $archive_file['filename'] ) ) { + continue; + } + $uncompressed_size += $archive_file['size']; $needed_dirs[] = $to . untrailingslashit( $archive_file['folder'] ? $archive_file['filename'] : dirname( $archive_file['filename'] ) ); @@ -1953,7 +1958,7 @@ function _unzip_file_pclzip( $file, $to, $needed_dirs = array() ) { } } - /** This filter is documented in src/wp-admin/includes/file.php */ + /** This filter is documented in wp-admin/includes/file.php */ $pre = apply_filters( 'pre_unzip_file', null, $file, $to, $needed_dirs, $required_space ); if ( null !== $pre ) { @@ -1980,7 +1985,7 @@ function _unzip_file_pclzip( $file, $to, $needed_dirs = array() ) { } } - /** This action is documented in src/wp-admin/includes/file.php */ + /** This filter is documented in wp-admin/includes/file.php */ $result = apply_filters( 'unzip_file', true, $file, $to, $needed_dirs, $required_space ); unset( $needed_dirs ); diff --git a/src/wp-admin/includes/ms.php b/src/wp-admin/includes/ms.php index bef197410bb83..625add8790a09 100644 --- a/src/wp-admin/includes/ms.php +++ b/src/wp-admin/includes/ms.php @@ -97,7 +97,7 @@ function wpmu_delete_blog( $blog_id, $drop = false ) { if ( $drop ) { wp_delete_site( $blog_id ); } else { - /** This action is documented in wp-includes/ms-blogs.php */ + /** This action is documented in wp-includes/ms-site.php */ do_action_deprecated( 'delete_blog', array( $blog_id, false ), '5.1.0' ); $users = get_users( @@ -116,7 +116,7 @@ function wpmu_delete_blog( $blog_id, $drop = false ) { update_blog_status( $blog_id, 'deleted', 1 ); - /** This action is documented in wp-includes/ms-blogs.php */ + /** This action is documented in wp-includes/ms-site.php */ do_action_deprecated( 'deleted_blog', array( $blog_id, false ), '5.1.0' ); } diff --git a/src/wp-admin/includes/plugin-install.php b/src/wp-admin/includes/plugin-install.php index 7607b8823bb99..8a2165fb91476 100644 --- a/src/wp-admin/includes/plugin-install.php +++ b/src/wp-admin/includes/plugin-install.php @@ -113,7 +113,7 @@ function plugins_api( $action, $args = array() ) { } if ( ! isset( $args->wp_version ) ) { - $args->wp_version = substr( wp_get_wp_version(), 0, 3 ); // x.y + $args->wp_version = wp_get_wp_version( 'major' ); // x.y } /** diff --git a/src/wp-admin/includes/schema.php b/src/wp-admin/includes/schema.php index 0c3f36338cf2b..2e142197dc21c 100644 --- a/src/wp-admin/includes/schema.php +++ b/src/wp-admin/includes/schema.php @@ -1033,7 +1033,6 @@ function populate_network( $network_id = 1, $domain = '', $email = '', $site_nam } // Check for network collision. - $network_exists = false; if ( is_multisite() ) { if ( get_network( $network_id ) ) { $errors->add( 'siteid_exists', __( 'The network already exists.' ) ); diff --git a/src/wp-admin/includes/theme.php b/src/wp-admin/includes/theme.php index ef5793b7dd118..d58b187d98d15 100644 --- a/src/wp-admin/includes/theme.php +++ b/src/wp-admin/includes/theme.php @@ -504,7 +504,7 @@ function themes_api( $action, $args = array() ) { } if ( ! isset( $args->wp_version ) ) { - $args->wp_version = substr( wp_get_wp_version(), 0, 3 ); // x.y + $args->wp_version = wp_get_wp_version( 'major' ); // x.y } /** diff --git a/src/wp-content/themes/twentyseventeen/inc/template-tags.php b/src/wp-content/themes/twentyseventeen/inc/template-tags.php index 6cdb6c6be5a53..0a543e16a4010 100644 --- a/src/wp-content/themes/twentyseventeen/inc/template-tags.php +++ b/src/wp-content/themes/twentyseventeen/inc/template-tags.php @@ -110,22 +110,23 @@ function twentyseventeen_entry_footer() { if ( ! function_exists( 'twentyseventeen_edit_link' ) ) : /** - * Returns an accessibility-friendly link to edit a post or page. + * Displays an accessibility-friendly link to edit a post or page. * - * This also gives a little context about what exactly we're editing - * (post or page?) so that users understand a bit more where they are in terms - * of the template hierarchy and their content. Helpful when/if the single-page - * layout with multiple posts/pages shown gets confusing. + * @since Twenty Seventeen 1.0 + * @since Twenty Seventeen 4.1 Added `$post_id` parameter. + * + * @param int $post_id Post ID. Default 0. */ - function twentyseventeen_edit_link() { + function twentyseventeen_edit_link( $post_id = 0 ) { edit_post_link( sprintf( /* translators: %s: Post title. Only visible to screen readers. */ __( 'Edit "%s"', 'twentyseventeen' ), - get_the_title() + get_the_title( $post_id ) ), '', - '' + '', + $post_id ); } endif; diff --git a/src/wp-includes/ID3/getid3.lib.php b/src/wp-includes/ID3/getid3.lib.php index 6cc2bc928a6ad..58865110fcff1 100644 --- a/src/wp-includes/ID3/getid3.lib.php +++ b/src/wp-includes/ID3/getid3.lib.php @@ -13,9 +13,9 @@ if (!defined('GETID3_LIBXML_OPTIONS') && defined('LIBXML_VERSION')) { if (LIBXML_VERSION >= 20621) { - define('GETID3_LIBXML_OPTIONS', LIBXML_NOENT | LIBXML_NONET | LIBXML_NOWARNING | LIBXML_COMPACT); + define('GETID3_LIBXML_OPTIONS', LIBXML_NONET | LIBXML_NOWARNING | LIBXML_COMPACT); } else { - define('GETID3_LIBXML_OPTIONS', LIBXML_NOENT | LIBXML_NONET | LIBXML_NOWARNING); + define('GETID3_LIBXML_OPTIONS', LIBXML_NONET | LIBXML_NOWARNING); } } diff --git a/src/wp-includes/admin-bar.php b/src/wp-includes/admin-bar.php index 9fc3c2b46b348..bcf3b997c89d4 100644 --- a/src/wp-includes/admin-bar.php +++ b/src/wp-includes/admin-bar.php @@ -934,6 +934,44 @@ function wp_admin_bar_edit_menu( $wp_admin_bar ) { } } +/** + * Adds the command palette trigger button. + * + * Displays a button in the admin bar that shows the keyboard shortcut + * for opening the command palette. + * + * @since 7.0.0 + * + * @param WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance. + */ +function wp_admin_bar_command_palette_menu( WP_Admin_Bar $wp_admin_bar ): void { + if ( ! is_admin() || ! wp_script_is( 'wp-core-commands', 'enqueued' ) ) { + return; + } + + $is_apple_os = (bool) preg_match( '/Macintosh|Mac OS X|Mac_PowerPC/i', $_SERVER['HTTP_USER_AGENT'] ?? '' ); + $shortcut_label = $is_apple_os + ? _x( '⌘K', 'keyboard shortcut to open the command palette' ) + : _x( 'Ctrl+K', 'keyboard shortcut to open the command palette' ); + $title = sprintf( + '%s %s', + $shortcut_label, + /* translators: Hidden accessibility text. */ + __( 'Open command palette' ), + ); + $wp_admin_bar->add_node( + array( + 'id' => 'command-palette', + 'title' => $title, + 'href' => '#', + 'meta' => array( + 'class' => 'hide-if-no-js', + 'onclick' => 'wp.data.dispatch( "core/commands" ).open(); return false;', + ), + ) + ); +} + /** * Adds "Add New" menu. * diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php index 3ed311915d158..8c6e452b214ad 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php @@ -10,6 +10,7 @@ use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; +use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\ModalityEnum; @@ -66,6 +67,9 @@ * @method self as_output_schema(array $schema) Sets the output schema. * @method self as_output_modalities(ModalityEnum ...$modalities) Sets the output modalities. * @method self as_output_file_type(FileTypeEnum $fileType) Sets the output file type. + * @method self as_output_media_orientation(MediaOrientationEnum $orientation) Sets the output media orientation. + * @method self as_output_media_aspect_ratio(string $aspectRatio) Sets the output media aspect ratio. + * @method self as_output_speech_voice(string $voice) Sets the output speech voice. * @method self as_json_response(?array $schema = null) Configures the prompt for JSON response output. * @method bool|WP_Error is_supported(?CapabilityEnum $capability = null) Checks if the prompt is supported for the given capability. * @method bool is_supported_for_text_generation() Checks if the prompt is supported for text generation. @@ -80,6 +84,7 @@ * @method GenerativeAiResult|WP_Error generate_image_result() Generates an image result from the prompt. * @method GenerativeAiResult|WP_Error generate_speech_result() Generates a speech result from the prompt. * @method GenerativeAiResult|WP_Error convert_text_to_speech_result() Converts text to speech and returns the result. + * @method GenerativeAiResult|WP_Error generate_video_result() Generates a video result from the prompt. * @method string|WP_Error generate_text() Generates text from the prompt. * @method list|WP_Error generate_texts(?int $candidateCount = null) Generates multiple text candidates from the prompt. * @method File|WP_Error generate_image() Generates an image from the prompt. @@ -88,6 +93,8 @@ * @method list|WP_Error convert_text_to_speeches(?int $candidateCount = null) Converts text to multiple speech outputs. * @method File|WP_Error generate_speech() Generates speech from the prompt. * @method list|WP_Error generate_speeches(?int $candidateCount = null) Generates multiple speech outputs from the prompt. + * @method File|WP_Error generate_video() Generates a video from the prompt. + * @method list|WP_Error generate_videos(?int $candidateCount = null) Generates multiple videos from the prompt. */ class WP_AI_Client_Prompt_Builder { @@ -121,6 +128,7 @@ class WP_AI_Client_Prompt_Builder { 'generate_image_result' => true, 'generate_speech_result' => true, 'convert_text_to_speech_result' => true, + 'generate_video_result' => true, 'generate_text' => true, 'generate_texts' => true, 'generate_image' => true, @@ -129,6 +137,8 @@ class WP_AI_Client_Prompt_Builder { 'convert_text_to_speeches' => true, 'generate_speech' => true, 'generate_speeches' => true, + 'generate_video' => true, + 'generate_videos' => true, ); /** diff --git a/src/wp-includes/block-editor.php b/src/wp-includes/block-editor.php index 18152756d5b73..c21e8d9e9feec 100644 --- a/src/wp-includes/block-editor.php +++ b/src/wp-includes/block-editor.php @@ -338,6 +338,7 @@ function _wp_get_iframed_editor_assets() { * front-end assets for the content. */ add_filter( 'should_load_block_editor_scripts_and_styles', '__return_false' ); + /** This action is documented in wp-includes/script-loader.php */ do_action( 'enqueue_block_assets' ); remove_filter( 'should_load_block_editor_scripts_and_styles', '__return_false' ); diff --git a/src/wp-includes/block-supports/block-visibility.php b/src/wp-includes/block-supports/block-visibility.php index 756e0500418f4..1ba208e30c92e 100644 --- a/src/wp-includes/block-supports/block-visibility.php +++ b/src/wp-includes/block-supports/block-visibility.php @@ -139,6 +139,16 @@ function wp_render_block_visibility_support( $block_content, $block ) { $processor = new WP_HTML_Tag_Processor( $block_content ); if ( $processor->next_tag() ) { $processor->add_class( implode( ' ', $class_names ) ); + + /* + * Set all IMG tags to be `fetchpriority=auto` so that wp_get_loading_optimization_attributes() won't add + * `fetchpriority=high` or increment the media count to affect whether subsequent IMG tags get `loading=lazy`. + */ + do { + if ( 'IMG' === $processor->get_tag() ) { + $processor->set_attribute( 'fetchpriority', 'auto' ); + } + } while ( $processor->next_tag() ); $block_content = $processor->get_updated_html(); } } diff --git a/src/wp-includes/block-template-utils.php b/src/wp-includes/block-template-utils.php index dfda03f3c6bfc..3995c1e3b61d8 100644 --- a/src/wp-includes/block-template-utils.php +++ b/src/wp-includes/block-template-utils.php @@ -1553,7 +1553,7 @@ function wp_generate_block_templates_export_file() { $theme_json_raw = $tree->get_data(); // If a version is defined, add a schema. if ( $theme_json_raw['version'] ) { - $theme_json_version = 'wp/' . substr( $wp_version, 0, 3 ); + $theme_json_version = 'wp/' . wp_get_branch_version( $wp_version ); $schema = array( '$schema' => 'https://schemas.wp.org/' . $theme_json_version . '/theme.json' ); $theme_json_raw = array_merge( $schema, $theme_json_raw ); } diff --git a/src/wp-includes/category-template.php b/src/wp-includes/category-template.php index 790cd3e0d0486..5c304072ed5d8 100644 --- a/src/wp-includes/category-template.php +++ b/src/wp-includes/category-template.php @@ -1258,8 +1258,8 @@ function tag_description( $tag = 0 ) { * @since 2.8.0 * @since 4.9.2 The `$taxonomy` parameter was deprecated. * - * @param int $term Optional. Term ID. Defaults to the current term ID. - * @param null $deprecated Deprecated. Not used. + * @param int $term Optional. Term ID. Defaults to the current term ID. + * @param mixed $deprecated Not used. * @return string Term description, if available. */ function term_description( $term = 0, $deprecated = null ) { diff --git a/src/wp-includes/class-wp-admin-bar.php b/src/wp-includes/class-wp-admin-bar.php index dfebbb20e4c86..e1f7282f82ab9 100644 --- a/src/wp-includes/class-wp-admin-bar.php +++ b/src/wp-includes/class-wp-admin-bar.php @@ -169,7 +169,7 @@ public function add_node( $args ) { 'my-blogs' => array( 'my-sites', '3.3' ), ); - if ( isset( $back_compat_parents[ $args['parent'] ] ) ) { + if ( is_string( $args['parent'] ) && isset( $back_compat_parents[ $args['parent'] ] ) ) { list( $new_parent, $version ) = $back_compat_parents[ $args['parent'] ]; _deprecated_argument( __METHOD__, $version, sprintf( 'Use %s as the parent for the %s admin bar node instead of %s.', $new_parent, $args['id'], $args['parent'] ) ); $args['parent'] = $new_parent; @@ -661,6 +661,9 @@ public function add_menus() { add_action( 'admin_bar_menu', 'wp_admin_bar_customize_menu', 40 ); add_action( 'admin_bar_menu', 'wp_admin_bar_updates_menu', 50 ); + // Command palette. + add_action( 'admin_bar_menu', 'wp_admin_bar_command_palette_menu', 55 ); + // Content-related. if ( ! is_network_admin() && ! is_user_admin() ) { add_action( 'admin_bar_menu', 'wp_admin_bar_comments_menu', 60 ); diff --git a/src/wp-includes/class-wp-block-parser.php b/src/wp-includes/class-wp-block-parser.php index bf8a59249d99d..8c619a7b47f2c 100644 --- a/src/wp-includes/class-wp-block-parser.php +++ b/src/wp-includes/class-wp-block-parser.php @@ -318,7 +318,7 @@ public function freeform( $inner_html ) { * * @internal * @since 5.0.0 - * @param null $length how many bytes of document text to output. + * @param null|int $length How many bytes of document text to output. */ public function add_freeform( $length = null ) { $length = $length ? $length : strlen( $this->document ) - $this->offset; diff --git a/src/wp-includes/class-wp-block-patterns-registry.php b/src/wp-includes/class-wp-block-patterns-registry.php index fe85160bac831..c9bcd63549ab4 100644 --- a/src/wp-includes/class-wp-block-patterns-registry.php +++ b/src/wp-includes/class-wp-block-patterns-registry.php @@ -173,12 +173,23 @@ private function get_content( $pattern_name, $outside_init_only = false ) { } else { $patterns = &$this->registered_patterns; } - if ( ! isset( $patterns[ $pattern_name ]['content'] ) && isset( $patterns[ $pattern_name ]['filePath'] ) ) { + + $file_path = $patterns[ $pattern_name ]['filePath'] ?? ''; + $is_stringy = is_string( $file_path ) || ( is_object( $file_path ) && method_exists( $file_path, '__toString' ) ); + $pattern_path = $is_stringy ? realpath( (string) $file_path ) : null; + if ( + ! isset( $patterns[ $pattern_name ]['content'] ) && + is_string( $pattern_path ) && + ( str_ends_with( $pattern_path, '.php' ) || str_ends_with( $pattern_path, '.html' ) ) && + is_file( $pattern_path ) && + is_readable( $pattern_path ) + ) { ob_start(); include $patterns[ $pattern_name ]['filePath']; $patterns[ $pattern_name ]['content'] = ob_get_clean(); unset( $patterns[ $pattern_name ]['filePath'] ); } + return $patterns[ $pattern_name ]['content']; } diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php new file mode 100644 index 0000000000000..855d93c803c89 --- /dev/null +++ b/src/wp-includes/class-wp-connector-registry.php @@ -0,0 +1,292 @@ + + * @phpstan-var array + */ + private array $registered_connectors = array(); + + /** + * Registers a new connector. + * + * @since 7.0.0 + * + * @param string $id The unique connector identifier. Must contain only lowercase + * alphanumeric characters and underscores. + * @param array $args { + * An associative array of arguments for the connector. + * + * @type string $name Required. The connector's display name. + * @type string $description Optional. The connector's description. Default empty string. + * @type string $logo_url Optional. URL to the connector's logo image. + * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. + * @type array $authentication { + * Required. Authentication configuration. + * + * @type string $method Required. The authentication method: 'api_key' or 'none'. + * @type string $credentials_url Optional. URL where users can obtain API credentials. + * } + * @type array $plugin { + * Optional. Plugin data for install/activate UI. + * + * @type string $slug The WordPress.org plugin slug. + * } + * } + * @return array|null The registered connector data on success, null on failure. + * + * @phpstan-param Connector $args + * @phpstan-return Connector|null + */ + public function register( string $id, array $args ): ?array { + if ( ! preg_match( '/^[a-z0-9_]+$/', $id ) ) { + _doing_it_wrong( + __METHOD__, + __( + 'Connector ID must contain only lowercase alphanumeric characters and underscores.' + ), + '7.0.0' + ); + return null; + } + + if ( $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" is already registered.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + // Validate required fields. + if ( empty( $args['name'] ) || ! is_string( $args['name'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "name" string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['type'] ) || ! is_string( $args['type'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "type" string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( ! isset( $args['authentication'] ) || ! is_array( $args['authentication'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires an "authentication" array.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['authentication']['method'] ) || ! in_array( $args['authentication']['method'], array( 'api_key', 'none' ), true ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication method must be "api_key" or "none".' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $connector = array( + 'name' => $args['name'], + 'description' => isset( $args['description'] ) && is_string( $args['description'] ) ? $args['description'] : '', + 'type' => $args['type'], + 'authentication' => array( + 'method' => $args['authentication']['method'], + ), + ); + + if ( ! empty( $args['logo_url'] ) && is_string( $args['logo_url'] ) ) { + $connector['logo_url'] = $args['logo_url']; + } + + if ( 'api_key' === $args['authentication']['method'] ) { + if ( ! empty( $args['authentication']['credentials_url'] ) && is_string( $args['authentication']['credentials_url'] ) ) { + $connector['authentication']['credentials_url'] = $args['authentication']['credentials_url']; + } + $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key"; + } + + if ( ! empty( $args['plugin'] ) && is_array( $args['plugin'] ) ) { + $connector['plugin'] = $args['plugin']; + } + + $this->registered_connectors[ $id ] = $connector; + return $connector; + } + + /** + * Unregisters a connector. + * + * @since 7.0.0 + * + * @param string $id The connector identifier. + * @return array|null The unregistered connector data on success, null on failure. + * + * @phpstan-return Connector|null + */ + public function unregister( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $unregistered = $this->registered_connectors[ $id ]; + unset( $this->registered_connectors[ $id ] ); + + return $unregistered; + } + + /** + * Retrieves the list of all registered connectors. + * + * Do not use this method directly. Instead, use the `wp_get_connectors()` function. + * + * @since 7.0.0 + * + * @see wp_get_connectors() + * + * @return array Connector settings keyed by connector ID. + * + * @phpstan-return array + */ + public function get_all_registered(): array { + return $this->registered_connectors; + } + + /** + * Checks if a connector is registered. + * + * Do not use this method directly. Instead, use the `wp_is_connector_registered()` function. + * + * @since 7.0.0 + * + * @see wp_is_connector_registered() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ + public function is_registered( string $id ): bool { + return isset( $this->registered_connectors[ $id ] ); + } + + /** + * Retrieves a registered connector. + * + * Do not use this method directly. Instead, use the `wp_get_connector()` function. + * + * @since 7.0.0 + * + * @see wp_get_connector() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if it is not registered. + * @phpstan-return Connector|null + */ + public function get_registered( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + return $this->registered_connectors[ $id ]; + } + + /** + * Retrieves the main instance of the registry class. + * + * @since 7.0.0 + * + * @return WP_Connector_Registry|null The main registry instance, or null if not yet initialized. + */ + public static function get_instance(): ?self { + return self::$instance; + } + + /** + * Sets the main instance of the registry class. + * + * @since 7.0.0 + * @access private + * + * @param WP_Connector_Registry $registry The registry instance. + */ + public static function set_instance( WP_Connector_Registry $registry ): void { + if ( ! doing_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The connector registry instance must be set during the init action.' ), + '7.0.0' + ); + return; + } + + self::$instance = $registry; + } +} diff --git a/src/wp-includes/class-wp-customize-widgets.php b/src/wp-includes/class-wp-customize-widgets.php index 356dca287d3fe..10f178e10fd84 100644 --- a/src/wp-includes/class-wp-customize-widgets.php +++ b/src/wp-includes/class-wp-customize-widgets.php @@ -338,7 +338,7 @@ public function customize_controls_init() { /** This action is documented in wp-admin/includes/ajax-actions.php */ do_action( 'widgets.php' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores - /** This action is documented in wp-admin/widgets.php */ + /** This action is documented in wp-admin/widgets-form.php */ do_action( 'sidebar_admin_setup' ); } @@ -897,7 +897,7 @@ public function enqueue_scripts() { wp_enqueue_script( 'wp-customize-widgets' ); wp_enqueue_style( 'wp-customize-widgets' ); - /** This action is documented in edit-form-blocks.php */ + /** This action is documented in wp-admin/edit-form-blocks.php */ do_action( 'enqueue_block_editor_assets' ); } } @@ -1722,7 +1722,7 @@ public function wp_ajax_update_widget() { /** This action is documented in wp-admin/includes/ajax-actions.php */ do_action( 'widgets.php' ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores - /** This action is documented in wp-admin/widgets.php */ + /** This action is documented in wp-admin/widgets-form.php */ do_action( 'sidebar_admin_setup' ); $widget_id = $this->get_post_value( 'widget-id' ); diff --git a/src/wp-includes/class-wp-http-ixr-client.php b/src/wp-includes/class-wp-http-ixr-client.php index f8b0b144f8b26..d4f7936cca177 100644 --- a/src/wp-includes/class-wp-http-ixr-client.php +++ b/src/wp-includes/class-wp-http-ixr-client.php @@ -89,7 +89,7 @@ public function query( ...$args ) { echo '
' . htmlspecialchars( $xml ) . "\n
\n\n"; } - $response = wp_remote_post( $url, $args ); + $response = wp_safe_remote_post( $url, $args ); if ( is_wp_error( $response ) ) { $errno = $response->get_error_code(); diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 1eeeba6d19afd..2eacfb68a2343 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -3469,21 +3469,21 @@ public function get_posts() { } if ( ! empty( $this->posts ) && $this->is_comment_feed && $this->is_singular ) { - /** This filter is documented in wp-includes/query.php */ + /** This filter is documented in wp-includes/class-wp-query.php */ $cjoin = apply_filters_ref_array( 'comment_feed_join', array( '', &$this ) ); - /** This filter is documented in wp-includes/query.php */ + /** This filter is documented in wp-includes/class-wp-query.php */ $cwhere = apply_filters_ref_array( 'comment_feed_where', array( "WHERE comment_post_ID = '{$this->posts[0]->ID}' AND comment_approved = '1'", &$this ) ); - /** This filter is documented in wp-includes/query.php */ + /** This filter is documented in wp-includes/class-wp-query.php */ $cgroupby = apply_filters_ref_array( 'comment_feed_groupby', array( '', &$this ) ); $cgroupby = ( ! empty( $cgroupby ) ) ? 'GROUP BY ' . $cgroupby : ''; - /** This filter is documented in wp-includes/query.php */ + /** This filter is documented in wp-includes/class-wp-query.php */ $corderby = apply_filters_ref_array( 'comment_feed_orderby', array( 'comment_date_gmt DESC', &$this ) ); $corderby = ( ! empty( $corderby ) ) ? 'ORDER BY ' . $corderby : ''; - /** This filter is documented in wp-includes/query.php */ + /** This filter is documented in wp-includes/class-wp-query.php */ $climits = apply_filters_ref_array( 'comment_feed_limits', array( 'LIMIT ' . get_option( 'posts_per_rss' ), &$this ) ); $comments_request = "SELECT {$wpdb->comments}.comment_ID FROM {$wpdb->comments} $cjoin $cwhere $cgroupby $corderby $climits"; diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index 486dca3009489..b098de7e20e56 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -417,19 +417,32 @@ public function do_item( $handle, $group = false ) { $src = $this->base_url . $src; } - $query_args = array(); + $ver_to_add = ''; if ( empty( $obj->ver ) && null !== $obj->ver && is_string( $this->default_version ) ) { - $query_args['ver'] = $this->default_version; + $ver_to_add = $this->default_version; } elseif ( is_scalar( $obj->ver ) ) { - $query_args['ver'] = (string) $obj->ver; + $ver_to_add = (string) $obj->ver; } - if ( isset( $this->args[ $handle ] ) ) { - parse_str( $this->args[ $handle ], $parsed_args ); - if ( $parsed_args ) { - $query_args = array_merge( $query_args, $parsed_args ); + + $added_args = (string) ( $this->args[ $handle ] ?? '' ); + + if ( '' !== $ver_to_add || '' !== $added_args ) { + $fragment = strstr( $src, '#' ); + if ( false !== $fragment ) { + $src = substr( $src, 0, -strlen( $fragment ) ); + } + + if ( '' !== $ver_to_add ) { + $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . 'ver=' . rawurlencode( $ver_to_add ); + } + if ( '' !== $added_args ) { + $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . $added_args; + } + + if ( false !== $fragment ) { + $src .= $fragment; } } - $src = add_query_arg( rawurlencode_deep( $query_args ), $src ); /** This filter is documented in wp-includes/class-wp-scripts.php */ $src = esc_url_raw( apply_filters( 'script_loader_src', $src, $handle ) ); diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index 53437fe23b1e3..1edaeedb2660b 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -407,19 +407,32 @@ public function _css_href( $src, $ver, $handle ) { $src = $this->base_url . $src; } - $query_args = array(); + $ver_to_add = ''; if ( empty( $ver ) && null !== $ver && is_string( $this->default_version ) ) { - $query_args['ver'] = $this->default_version; + $ver_to_add = $this->default_version; } elseif ( is_scalar( $ver ) ) { - $query_args['ver'] = (string) $ver; + $ver_to_add = (string) $ver; } - if ( isset( $this->args[ $handle ] ) ) { - parse_str( $this->args[ $handle ], $parsed_args ); - if ( $parsed_args ) { - $query_args = array_merge( $query_args, $parsed_args ); + + $added_args = (string) ( $this->args[ $handle ] ?? '' ); + + if ( '' !== $ver_to_add || '' !== $added_args ) { + $fragment = strstr( $src, '#' ); + if ( false !== $fragment ) { + $src = substr( $src, 0, -strlen( $fragment ) ); + } + + if ( '' !== $ver_to_add ) { + $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . 'ver=' . rawurlencode( $ver_to_add ); + } + if ( '' !== $added_args ) { + $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . $added_args; + } + + if ( false !== $fragment ) { + $src .= $fragment; } } - $src = add_query_arg( rawurlencode_deep( $query_args ), $src ); /** * Filters an enqueued style's fully-qualified URL. diff --git a/src/wp-includes/class-wp-text-diff-renderer-table.php b/src/wp-includes/class-wp-text-diff-renderer-table.php index 25272265e261b..e02578da48163 100644 --- a/src/wp-includes/class-wp-text-diff-renderer-table.php +++ b/src/wp-includes/class-wp-text-diff-renderer-table.php @@ -235,7 +235,7 @@ public function _deleted( $lines, $encode = true ) { if ( $encode ) { $processed_line = htmlspecialchars( $line ); - /** This filter is documented in wp-includes/wp-diff.php */ + /** This filter is documented in wp-includes/class-wp-text-diff-renderer-table.php */ $line = apply_filters( 'process_text_diff_html', $processed_line, $line, 'deleted' ); } if ( $this->_show_split_view ) { @@ -260,7 +260,7 @@ public function _context( $lines, $encode = true ) { if ( $encode ) { $processed_line = htmlspecialchars( $line ); - /** This filter is documented in wp-includes/wp-diff.php */ + /** This filter is documented in wp-includes/class-wp-text-diff-renderer-table.php */ $line = apply_filters( 'process_text_diff_html', $processed_line, $line, 'unchanged' ); } if ( $this->_show_split_view ) { diff --git a/src/wp-includes/class-wp-widget.php b/src/wp-includes/class-wp-widget.php index e72b2798cbab9..5ad32f49378a8 100644 --- a/src/wp-includes/class-wp-widget.php +++ b/src/wp-includes/class-wp-widget.php @@ -546,9 +546,9 @@ public function form_callback( $widget_args = 1 ) { * * @since 2.8.0 * - * @param WP_Widget $widget The widget instance (passed by reference). - * @param null $return Return null if new fields are added. - * @param array $instance An array of the widget's settings. + * @param WP_Widget $widget The widget instance (passed by reference). + * @param null|string $return Default 'noform'. Return null if new fields are added. + * @param array $instance An array of the widget's settings. */ do_action_ref_array( 'in_widget_form', array( &$this, &$return, $instance ) ); } diff --git a/src/wp-includes/collaboration.php b/src/wp-includes/collaboration.php index 6fdbe2889ba7a..31f816c87b670 100644 --- a/src/wp-includes/collaboration.php +++ b/src/wp-includes/collaboration.php @@ -12,13 +12,25 @@ * @since 7.0.0 * * @access private + * + * @global string $pagenow The filename of the current screen. */ function wp_collaboration_inject_setting() { - if ( get_option( 'wp_enable_real_time_collaboration' ) ) { - wp_add_inline_script( - 'wp-core-data', - 'window._wpCollaborationEnabled = true;', - 'after' - ); + global $pagenow; + + if ( ! get_option( 'wp_enable_real_time_collaboration' ) ) { + return; } + + // Disable real-time collaboration on the site editor. + $enabled = true; + if ( 'site-editor.php' === $pagenow ) { + $enabled = false; + } + + wp_add_inline_script( + 'wp-core-data', + 'window._wpCollaborationEnabled = ' . wp_json_encode( $enabled ) . ';', + 'after' + ); } diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 70d78ed33c848..0f102d1ea80ee 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -807,7 +807,7 @@ function wp_allow_comment( $commentdata, $wp_error = false ) { ); if ( $is_flood ) { - /** This filter is documented in wp-includes/comment-template.php */ + /** This filter is documented in wp-includes/comment.php */ $comment_flood_message = apply_filters( 'comment_flood_message', __( 'You are posting comments too quickly. Slow down.' ) ); return new WP_Error( 'comment_flood', $comment_flood_message, 429 ); diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 0da60353705c2..575f71da7766c 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -10,87 +10,84 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; - /** - * Masks an API key, showing only the last 4 characters. + * Checks if a connector is registered. * * @since 7.0.0 - * @access private * - * @param string $key The API key to mask. - * @return string The masked key, e.g. "************fj39". + * @see WP_Connector_Registry::is_registered() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. */ -function _wp_connectors_mask_api_key( string $key ): string { - if ( strlen( $key ) <= 4 ) { - return $key; +function wp_is_connector_registered( string $id ): bool { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return false; } - return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); + return $registry->is_registered( $id ); } /** - * Checks whether an API key is valid for a given provider. + * Retrieves a registered connector. * * @since 7.0.0 - * @access private * - * @param string $key The API key to check. - * @param string $provider_id The WP AI client provider ID. - * @return bool|null True if valid, false if invalid, null if unable to determine. - */ -function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool { - try { - $registry = AiClient::defaultRegistry(); - - if ( ! $registry->hasProvider( $provider_id ) ) { - _doing_it_wrong( - __FUNCTION__, - sprintf( - /* translators: %s: AI provider ID. */ - __( 'The provider "%s" is not registered in the AI client registry.' ), - $provider_id - ), - '7.0.0' - ); - return null; - } - - $registry->setProviderRequestAuthentication( - $provider_id, - new ApiKeyRequestAuthentication( $key ) - ); - - return $registry->isProviderConfigured( $provider_id ); - } catch ( Exception $e ) { - wp_trigger_error( __FUNCTION__, $e->getMessage() ); - return null; - } -} - -/** - * Retrieves the real (unmasked) value of a connector API key. + * @see WP_Connector_Registry::get_registered() * - * Temporarily removes the masking filter, reads the option, then re-adds it. + * @param string $id The connector identifier. + * @return array|null { + * Connector data, or null if not registered. * - * @since 7.0.0 - * @access private + * @type string $name The connector's display name. + * @type string $description The connector's description. + * @type string $logo_url Optional. URL to the connector's logo image. + * @type string $type The connector type. Currently, only 'ai_provider' is supported. + * @type array $authentication { + * Authentication configuration. When method is 'api_key', includes + * credentials_url and setting_name. When 'none', only method is present. * - * @param string $option_name The option name for the API key. - * @param callable $mask_callback The mask filter function. - * @return string The real API key value. + * @type string $method The authentication method: 'api_key' or 'none'. + * @type string $credentials_url Optional. URL where users can obtain API credentials. + * @type string $setting_name Optional. The setting name for the API key. + * } + * @type array $plugin { + * Optional. Plugin data for install/activate UI. + * + * @type string $slug The WordPress.org plugin slug. + * } + * } + * @phpstan-return ?array{ + * name: non-empty-string, + * description: non-empty-string, + * logo_url?: non-empty-string, + * type: 'ai_provider', + * authentication: array{ + * method: 'api_key'|'none', + * credentials_url?: non-empty-string, + * setting_name?: non-empty-string + * }, + * plugin?: array{ + * slug: non-empty-string + * } + * } */ -function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string { - remove_filter( "option_{$option_name}", $mask_callback ); - $value = get_option( $option_name, '' ); - add_filter( "option_{$option_name}", $mask_callback ); - return (string) $value; +function wp_get_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $id ); } /** - * Gets the registered connector settings. + * Retrieves all registered connectors. * * @since 7.0.0 - * @access private + * + * @see WP_Connector_Registry::get_all_registered() * * @return array { * Connector settings keyed by connector ID. @@ -98,25 +95,105 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca * @type array ...$0 { * Data for a single connector. * - * @type string $name The connector's display name. - * @type string $description The connector's description. - * @type string $type The connector type. Currently, only 'ai_provider' is supported. - * @type array $plugin Optional. Plugin data for install/activate UI. - * @type string $slug The WordPress.org plugin slug. - * } - * @type array $authentication { + * @type string $name The connector's display name. + * @type string $description The connector's description. + * @type string $logo_url Optional. URL to the connector's logo image. + * @type string $type The connector type. Currently, only 'ai_provider' is supported. + * @type array $authentication { * Authentication configuration. When method is 'api_key', includes * credentials_url and setting_name. When 'none', only method is present. * - * @type string $method The authentication method: 'api_key' or 'none'. - * @type string|null $credentials_url Optional. URL where users can obtain API credentials. - * @type string $setting_name Optional. The setting name for the API key. + * @type string $method The authentication method: 'api_key' or 'none'. + * @type string $credentials_url Optional. URL where users can obtain API credentials. + * @type string $setting_name Optional. The setting name for the API key. + * } + * @type array $plugin { + * Optional. Plugin data for install/activate UI. + * + * @type string $slug The WordPress.org plugin slug. * } * } * } + * @phpstan-return array + */ +function wp_get_connectors(): array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); +} + +/** + * Resolves an AI provider logo file path to a URL. + * + * Converts an absolute file path to a plugin URL. The path must reside within + * the plugins or must-use plugins directory. + * + * @since 7.0.0 + * @access private + * + * @param string $path Absolute path to the logo file. + * @return string|null The URL to the logo file, or null if the path is invalid. */ -function _wp_connectors_get_connector_settings(): array { - $connectors = array( +function _wp_connectors_resolve_ai_provider_logo_url( string $path ): ?string { + if ( ! $path ) { + return null; + } + + $path = wp_normalize_path( $path ); + + if ( ! file_exists( $path ) ) { + return null; + } + + $mu_plugin_dir = wp_normalize_path( WPMU_PLUGIN_DIR ); + if ( str_starts_with( $path, $mu_plugin_dir . '/' ) ) { + return plugins_url( substr( $path, strlen( $mu_plugin_dir ) ), WPMU_PLUGIN_DIR . '/.' ); + } + + $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); + if ( str_starts_with( $path, $plugin_dir . '/' ) ) { + return plugins_url( substr( $path, strlen( $plugin_dir ) ) ); + } + + _doing_it_wrong( + __FUNCTION__, + __( 'Provider logo path must be located within the plugins or must-use plugins directory.' ), + '7.0.0' + ); + + return null; +} + +/** + * Initializes the connector registry with default connectors and fires the registration action. + * + * Creates the registry instance, registers built-in connectors (which cannot be unhooked), + * and then fires the `wp_connectors_init` action for plugins to register their own connectors. + * + * @since 7.0.0 + * @access private + */ +function _wp_connectors_init(): void { + $registry = new WP_Connector_Registry(); + WP_Connector_Registry::set_instance( $registry ); + // Built-in connectors. + $defaults = array( 'anthropic' => array( 'name' => 'Anthropic', 'description' => __( 'Text generation with Claude.' ), @@ -155,10 +232,12 @@ function _wp_connectors_get_connector_settings(): array { ), ); - $registry = AiClient::defaultRegistry(); + // Merge AI Client registry data on top of defaults. + // Registry values (from provider plugins) take precedence over hardcoded fallbacks. + $ai_registry = AiClient::defaultRegistry(); - foreach ( $registry->getRegisteredProviderIds() as $connector_id ) { - $provider_class_name = $registry->getProviderClassName( $connector_id ); + foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) { + $provider_class_name = $ai_registry->getProviderClassName( $connector_id ); $provider_metadata = $provider_class_name::metadata(); $auth_method = $provider_metadata->getAuthenticationMethod(); @@ -176,49 +255,180 @@ function _wp_connectors_get_connector_settings(): array { $name = $provider_metadata->getName(); $description = $provider_metadata->getDescription(); + $logo_url = $provider_metadata->getLogoPath() + ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) + : null; - if ( isset( $connectors[ $connector_id ] ) ) { + if ( isset( $defaults[ $connector_id ] ) ) { // Override fields with non-empty registry values. if ( $name ) { - $connectors[ $connector_id ]['name'] = $name; + $defaults[ $connector_id ]['name'] = $name; } if ( $description ) { - $connectors[ $connector_id ]['description'] = $description; + $defaults[ $connector_id ]['description'] = $description; + } + if ( $logo_url ) { + $defaults[ $connector_id ]['logo_url'] = $logo_url; } // Always update auth method; keep existing credentials_url as fallback. - $connectors[ $connector_id ]['authentication']['method'] = $authentication['method']; + $defaults[ $connector_id ]['authentication']['method'] = $authentication['method']; if ( ! empty( $authentication['credentials_url'] ) ) { - $connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; + $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; } } else { - $connectors[ $connector_id ] = array( + $defaults[ $connector_id ] = array( 'name' => $name ? $name : ucwords( $connector_id ), 'description' => $description ? $description : '', 'type' => 'ai_provider', 'authentication' => $authentication, + 'logo_url' => $logo_url, ); } } - ksort( $connectors ); + // Register all default connectors directly on the registry. + foreach ( $defaults as $id => $args ) { + $registry->register( $id, $args ); + } + + /** + * Fires when the connector registry is ready for plugins to register connectors. + * + * Default connectors have already been registered at this point and cannot be + * unhooked. Use `$registry->register()` within this action to add new connectors. + * + * Example usage: + * + * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) { + * $registry->register( + * 'my_custom_ai', + * array( + * 'name' => __( 'My Custom AI', 'my-plugin' ), + * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), + * 'type' => 'ai_provider', + * 'authentication' => array( + * 'method' => 'api_key', + * 'credentials_url' => 'https://example.com/api-keys', + * ), + * ) + * ); + * } ); + * + * @since 7.0.0 + * + * @param WP_Connector_Registry $registry Connector registry instance. + */ + do_action( 'wp_connectors_init', $registry ); +} + +/** + * Masks an API key, showing only the last 4 characters. + * + * @since 7.0.0 + * @access private + * + * @param string $key The API key to mask. + * @return string The masked key, e.g. "************fj39". + */ +function _wp_connectors_mask_api_key( string $key ): string { + if ( strlen( $key ) <= 4 ) { + return $key; + } - // Add setting_name for connectors that use API key authentication. - foreach ( $connectors as $connector_id => $connector ) { - if ( 'api_key' === $connector['authentication']['method'] ) { - $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; + return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); +} + +/** + * Determines the source of an API key for a given provider. + * + * Checks in order: environment variable, PHP constant, database. + * Uses the same naming convention as the WP AI Client ProviderRegistry. + * + * @since 7.0.0 + * @access private + * + * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google'). + * @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key'). + * @return string The key source: 'env', 'constant', 'database', or 'none'. + */ +function _wp_connectors_get_api_key_source( string $provider_id, string $setting_name ): string { + // Convert provider ID to CONSTANT_CASE for env var name. + // e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'. + $constant_case_id = strtoupper( + preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) ) + ); + $env_var_name = "{$constant_case_id}_API_KEY"; + + // Check environment variable first. + $env_value = getenv( $env_var_name ); + if ( false !== $env_value && '' !== $env_value ) { + return 'env'; + } + + // Check PHP constant. + if ( defined( $env_var_name ) ) { + $const_value = constant( $env_var_name ); + if ( is_string( $const_value ) && '' !== $const_value ) { + return 'constant'; } } - return $connectors; + // Check database. + $db_value = get_option( $setting_name, '' ); + if ( '' !== $db_value ) { + return 'database'; + } + + return 'none'; +} + +/** + * Checks whether an API key is valid for a given provider. + * + * @since 7.0.0 + * @access private + * + * @param string $key The API key to check. + * @param string $provider_id The WP AI client provider ID. + * @return bool|null True if valid, false if invalid, null if unable to determine. + */ +function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): ?bool { + try { + $registry = AiClient::defaultRegistry(); + + if ( ! $registry->hasProvider( $provider_id ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: %s: AI provider ID. */ + __( 'The provider "%s" is not registered in the AI client registry.' ), + $provider_id + ), + '7.0.0' + ); + return null; + } + + $registry->setProviderRequestAuthentication( + $provider_id, + new ApiKeyRequestAuthentication( $key ) + ); + + return $registry->isProviderConfigured( $provider_id ); + } catch ( Exception $e ) { + wp_trigger_error( __FUNCTION__, $e->getMessage() ); + return null; + } } /** - * Validates connector API keys in the REST response when explicitly requested. + * Masks and validates connector API keys in REST responses. * - * Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector - * fields via `_fields`. For each requested connector field, it validates the unmasked - * key against the provider and replaces the response value with `invalid_key` if - * validation fails. + * On every `/wp/v2/settings` response, masks connector API key values so raw + * keys are never exposed via the REST API. + * + * On POST or PUT requests, validates each updated key against the provider + * before masking. If validation fails, the key is reverted to an empty string. * * @since 7.0.0 * @access private @@ -226,72 +436,76 @@ function _wp_connectors_get_connector_settings(): array { * @param WP_REST_Response $response The response object. * @param WP_REST_Server $server The server instance. * @param WP_REST_Request $request The request object. - * @return WP_REST_Response The potentially modified response. + * @return WP_REST_Response The modified response with masked/validated keys. */ -function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { +function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { if ( '/wp/v2/settings' !== $request->get_route() ) { return $response; } - $fields = $request->get_param( '_fields' ); - if ( ! $fields ) { - return $response; - } - - if ( is_array( $fields ) ) { - $requested = $fields; - } else { - $requested = array_map( 'trim', explode( ',', $fields ) ); - } - $data = $response->get_data(); if ( ! is_array( $data ) ) { return $response; } - foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { + $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method(); + + foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } $setting_name = $auth['setting_name']; - if ( ! in_array( $setting_name, $requested, true ) ) { + if ( ! array_key_exists( $setting_name, $data ) ) { continue; } - $real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); - if ( '' === $real_key ) { - continue; + $value = $data[ $setting_name ]; + + // On update, validate the key before masking. + if ( $is_update && is_string( $value ) && '' !== $value ) { + if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) { + update_option( $setting_name, '' ); + $data[ $setting_name ] = ''; + continue; + } } - if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) { - $data[ $setting_name ] = 'invalid_key'; + // Mask the key in the response. + if ( is_string( $value ) && '' !== $value ) { + $data[ $setting_name ] = _wp_connectors_mask_api_key( $value ); } } $response->set_data( $data ); return $response; } -add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 ); +add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 ); /** - * Registers default connector settings and mask/sanitize filters. + * Registers default connector settings. * * @since 7.0.0 * @access private */ function _wp_register_default_connector_settings(): void { - foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { + $ai_registry = AiClient::defaultRegistry(); + + foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } - $setting_name = $auth['setting_name']; + // Skip registering the setting if the provider is not in the registry. + if ( ! $ai_registry->hasProvider( $connector_id ) ) { + continue; + } + register_setting( 'connectors', - $setting_name, + $auth['setting_name'], array( 'type' => 'string', 'label' => sprintf( @@ -306,18 +520,9 @@ function _wp_register_default_connector_settings(): void { ), 'default' => '', 'show_in_rest' => true, - 'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string { - $value = sanitize_text_field( $value ); - if ( '' === $value ) { - return $value; - } - - $valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id ); - return true === $valid ? $value : ''; - }, + 'sanitize_callback' => 'sanitize_text_field', ) ); - add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); } } add_action( 'init', '_wp_register_default_connector_settings', 20 ); @@ -330,8 +535,8 @@ function _wp_register_default_connector_settings(): void { */ function _wp_connectors_pass_default_keys_to_ai_client(): void { try { - $registry = AiClient::defaultRegistry(); - foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { + $ai_registry = AiClient::defaultRegistry(); + foreach ( wp_get_connectors() as $connector_id => $connector_data ) { if ( 'ai_provider' !== $connector_data['type'] ) { continue; } @@ -341,18 +546,28 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } - $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); - if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) { + if ( ! $ai_registry->hasProvider( $connector_id ) ) { + continue; + } + + // Skip if the key is already provided via env var or constant. + $key_source = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ); + if ( 'env' === $key_source || 'constant' === $key_source ) { continue; } - $registry->setProviderRequestAuthentication( + $api_key = get_option( $auth['setting_name'], '' ); + if ( '' === $api_key ) { + continue; + } + + $ai_registry->setProviderRequestAuthentication( $connector_id, new ApiKeyRequestAuthentication( $api_key ) ); } } catch ( Exception $e ) { - wp_trigger_error( __FUNCTION__, $e->getMessage() ); + wp_trigger_error( __FUNCTION__, $e->getMessage() ); } } add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 ); @@ -363,33 +578,63 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { * @since 7.0.0 * @access private * - * @param array $data Existing script module data. - * @return array Script module data with connectors added. + * @param array $data Existing script module data. + * @return array Script module data with connectors added. */ function _wp_connectors_get_connector_script_module_data( array $data ): array { + $registry = AiClient::defaultRegistry(); + + // Build a slug-to-file map for plugin installation status. + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + $plugin_files_by_slug = array(); + foreach ( array_keys( get_plugins() ) as $plugin_file ) { + $slug = str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file ); + $plugin_files_by_slug[ $slug ] = $plugin_file; + } + $connectors = array(); - foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { + foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; $auth_out = array( 'method' => $auth['method'] ); if ( 'api_key' === $auth['method'] ) { $auth_out['settingName'] = $auth['setting_name'] ?? ''; $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; + $auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ?? '' ); + try { + $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); + } catch ( Exception $e ) { + $auth_out['isConnected'] = false; + } } $connector_out = array( 'name' => $connector_data['name'], 'description' => $connector_data['description'], + 'logoUrl' => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null, 'type' => $connector_data['type'], 'authentication' => $auth_out, ); - if ( ! empty( $connector_data['plugin'] ) ) { - $connector_out['plugin'] = $connector_data['plugin']; + if ( ! empty( $connector_data['plugin']['slug'] ) ) { + $plugin_slug = $connector_data['plugin']['slug']; + $plugin_file = $plugin_files_by_slug[ $plugin_slug ] ?? null; + + $is_installed = null !== $plugin_file; + $is_activated = $is_installed && is_plugin_active( $plugin_file ); + + $connector_out['plugin'] = array( + 'slug' => $plugin_slug, + 'isInstalled' => $is_installed, + 'isActivated' => $is_activated, + ); } $connectors[ $connector_id ] = $connector_out; } + ksort( $connectors ); $data['connectors'] = $connectors; return $data; } diff --git a/src/wp-includes/css/admin-bar.css b/src/wp-includes/css/admin-bar.css index 7f89c7a942de4..0b8d4ab41a809 100644 --- a/src/wp-includes/css/admin-bar.css +++ b/src/wp-includes/css/admin-bar.css @@ -694,6 +694,22 @@ html:lang(he-il) .rtl #wpadminbar * { display: none; } +/** + * Command Palette + */ +#wpadminbar #wp-admin-bar-command-palette .ab-icon { + display: none; /* Icon displayed only on mobile */ +} + +#wpadminbar #wp-admin-bar-command-palette .ab-icon:before { + content: "\f179"; + content: "\f179" / ''; +} + +#wpadminbar #wp-admin-bar-command-palette kbd { + background: transparent; +} + /** * Customize support classes */ @@ -892,7 +908,8 @@ html:lang(he-il) .rtl #wpadminbar * { #wpadminbar #wp-admin-bar-site-name > .ab-item:before, #wpadminbar #wp-admin-bar-site-editor > .ab-item:before, #wpadminbar #wp-admin-bar-customize > .ab-item:before, - #wpadminbar #wp-admin-bar-my-account > .ab-item:before { + #wpadminbar #wp-admin-bar-my-account > .ab-item:before, + #wpadminbar #wp-admin-bar-command-palette .ab-icon:before { display: block; text-indent: 0; font: normal 32px/1 dashicons; @@ -935,12 +952,18 @@ html:lang(he-il) .rtl #wpadminbar * { top: 3px; } - /* Comments */ - #wpadminbar #wp-admin-bar-comments .ab-icon { + /* Comments and Command Palette */ + #wpadminbar #wp-admin-bar-comments .ab-icon, + #wpadminbar #wp-admin-bar-command-palette .ab-icon { margin: 0; } - #wpadminbar #wp-admin-bar-comments .ab-icon:before { + #wpadminbar #wp-admin-bar-command-palette .ab-icon { + display: block; /* Icon is only shown on mobile, while the keyboard shortcut is hidden */ + } + + #wpadminbar #wp-admin-bar-comments .ab-icon:before, + #wpadminbar #wp-admin-bar-command-palette .ab-icon:before { display: block; font-size: 34px; height: 46px; @@ -1009,7 +1032,8 @@ html:lang(he-il) .rtl #wpadminbar * { #wpadminbar li#wp-admin-bar-new-content, #wpadminbar li#wp-admin-bar-edit, #wpadminbar li#wp-admin-bar-comments, - #wpadminbar li#wp-admin-bar-my-account { + #wpadminbar li#wp-admin-bar-my-account, + #wpadminbar li#wp-admin-bar-command-palette { display: block; } @@ -1040,7 +1064,8 @@ html:lang(he-il) .rtl #wpadminbar * { #wpadminbar #wp-admin-bar-comments, #wpadminbar #wp-admin-bar-new-content, #wpadminbar #wp-admin-bar-edit, - #wpadminbar #wp-admin-bar-my-account { + #wpadminbar #wp-admin-bar-my-account, + #wpadminbar #wp-admin-bar-command-palette { position: static; } diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 796cf00ec81e1..dd3786aef5059 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -539,6 +539,9 @@ add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); +// Connectors API. +add_action( 'init', '_wp_connectors_init', 15 ); + // Sitemaps actions. add_action( 'init', 'wp_sitemaps_get_server' ); @@ -604,7 +607,7 @@ add_action( 'enqueue_block_assets', 'wp_enqueue_classic_theme_styles' ); add_action( 'enqueue_block_assets', 'wp_enqueue_registered_block_scripts_and_styles' ); add_action( 'enqueue_block_assets', 'enqueue_block_styles_assets', 30 ); -add_action( 'init', 'wp_load_classic_theme_block_styles_on_demand', 8 ); // Must happen before register_core_block_style_handles() at priority 9. +add_action( 'wp_default_styles', 'wp_load_classic_theme_block_styles_on_demand', 0 ); // Must happen before wp_default_styles() and register_core_block_style_handles(). /* * `wp_enqueue_registered_block_scripts_and_styles` is bound to both * `enqueue_block_editor_assets` and `enqueue_block_assets` hooks diff --git a/src/wp-includes/deprecated.php b/src/wp-includes/deprecated.php index 390d632492b66..14f5c24aec914 100644 --- a/src/wp-includes/deprecated.php +++ b/src/wp-includes/deprecated.php @@ -150,6 +150,7 @@ function previous_post($format='%', $previous='previous post: ', $title='yes', $ $string = ''.$previous; if ( 'yes' == $title ) + /** This filter is documented in wp-includes/post-template.php */ $string .= apply_filters('the_title', $post->post_title, $post->ID); $string .= ''; $format = str_replace('%', $string, $format); @@ -185,6 +186,7 @@ function next_post($format='%', $next='next post: ', $title='yes', $in_same_cat= $string = ''.$next; if ( 'yes' == $title ) + /** This filter is documented in wp-includes/post-template.php */ $string .= apply_filters('the_title', $post->post_title, $post->ID); $string .= ''; $format = str_replace('%', $string, $format); @@ -1060,6 +1062,7 @@ function get_links_list($order = 'name') { // Handle each category. // Display the category name. + /** This filter is documented in wp-includes/bookmark-template.php */ echo '
  • ' . apply_filters('link_category', $cat->name ) . "

    \n\t
      \n"; // Call get_links() with all the appropriate params. get_links($cat->term_id, '
    • ', "
    • ", "\n", true, 'name', false); @@ -2702,6 +2705,7 @@ function get_boundary_post_rel_link($title = '%title', $in_same_cat = false, $ex $title = str_replace('%title', $post->post_title, $title); $title = str_replace('%date', $date, $title); + /** This filter is documented in wp-includes/post-template.php */ $title = apply_filters('the_title', $title, $post->ID); $link = $start ? "post_title, $title); $title = str_replace('%date', $date, $title); + /** This filter is documented in wp-includes/post-template.php */ $title = apply_filters('the_title', $title, $post->ID); $link = ". * } * @return bool Whether a tag was matched. + * + * @phpstan-impure */ public function next_tag( $query = null ): bool { $this->parse_query( $query ); @@ -5061,4 +5063,13 @@ public function get_doctype_info(): ?WP_HTML_Doctype_Info { * @since 6.7.0 */ const TEXT_IS_WHITESPACE = 'TEXT_IS_WHITESPACE'; + + /** + * Wakeup magic method. + * + * @since 6.9.2 + */ + public function __wakeup() { + throw new \LogicException( __CLASS__ . ' should never be unserialized' ); + } } diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php index b4cc86566d948..9b0e11b086ccf 100644 --- a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -1029,6 +1029,20 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process return; } + // Skip if the suffix is an event handler. + if ( str_starts_with( $entry['suffix'], 'on' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: The directive, e.g. data-wp-on--click. */ + __( 'Binding event handler attributes is not supported. Please use "%s" instead.' ), + esc_attr( 'data-wp-on--' . substr( $entry['suffix'], 2 ) ) + ), + '6.9.2' + ); + continue; + } + $result = $this->evaluate( $entry ); if ( diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index ed2f96503ac27..062f85308512f 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -2118,8 +2118,8 @@ function wp_kses_normalize_entities( $content, $context = 'html' ) { * * Here, each input is normalized to an appropriate output. */ - $content = preg_replace_callback( '/&#(0*[0-9]{1,7});/', 'wp_kses_normalize_entities2', $content ); - $content = preg_replace_callback( '/&#[Xx](0*[0-9A-Fa-f]{1,6});/', 'wp_kses_normalize_entities3', $content ); + $content = preg_replace_callback( '/&#(0*[1-9][0-9]{0,6});/', 'wp_kses_normalize_entities2', $content ); + $content = preg_replace_callback( '/&#[Xx](0*[1-9A-Fa-f][0-9A-Fa-f]{0,5});/', 'wp_kses_normalize_entities3', $content ); if ( 'xml' === $context ) { $content = preg_replace_callback( '/&([A-Za-z]{2,8}[0-9]{0,2});/', 'wp_kses_xml_named_entities', $content ); } else { diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index bfd2e58487429..fd215c800f9dc 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4568,7 +4568,7 @@ function wp_prepare_attachment_for_js( $attachment ) { if ( $attachment->post_parent ) { $post_parent = get_post( $attachment->post_parent ); - if ( $post_parent ) { + if ( $post_parent && current_user_can( 'read_post', $attachment->post_parent ) ) { $response['uploadedToTitle'] = $post_parent->post_title ? $post_parent->post_title : __( '(no title)' ); $response['uploadedToLink'] = get_edit_post_link( $attachment->post_parent, 'raw' ); } @@ -5967,6 +5967,7 @@ function wp_get_webp_info( $filename ) { * both attributes are present with those values. * * @since 6.3.0 + * @since 7.0.0 Support `fetchpriority=low` and `fetchpriority=auto` so that `loading=lazy` is not added and the media count is not increased. * * @global WP_Query $wp_query WordPress Query object. * @@ -6067,7 +6068,9 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) { } // Logic to handle a `fetchpriority` attribute that is already provided. - if ( isset( $attr['fetchpriority'] ) && 'high' === $attr['fetchpriority'] ) { + $existing_fetchpriority = ( $attr['fetchpriority'] ?? null ); + $is_low_fetchpriority = ( 'low' === $existing_fetchpriority ); + if ( 'high' === $existing_fetchpriority ) { /* * If the image was already determined to not be in the viewport (e.g. * from an already provided `loading` attribute), trigger a warning. @@ -6090,6 +6093,31 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) { } else { $maybe_in_viewport = true; } + } elseif ( $is_low_fetchpriority ) { + /* + * An IMG with fetchpriority=low is not initially displayed; it may be hidden in the Navigation Overlay, + * or it may be occluded in a non-initial carousel slide. Such images must not be lazy-loaded because the browser + * has no heuristic to know when to start loading them before the user needs to see them. + */ + $maybe_in_viewport = false; + + // Preserve fetchpriority=low. + $loading_attrs['fetchpriority'] = 'low'; + } elseif ( 'auto' === $existing_fetchpriority ) { + /* + * When a block's visibility support identifies that the block is conditionally displayed based on the viewport + * size, then it adds `fetchpriority=auto` to the block's IMG tags. These images must not be fetched with high + * priority because they could be erroneously loaded in viewports which do not even display them. Contrarily, + * they must not get `fetchpriority=low` because they may in fact be displayed in the current viewport. So as + * a signal to indicate that an IMG may be in the viewport, `fetchpriority=auto` is added. This has the effect + * here of preventing the media count from being increased, so that images hidden with block visibility do not + * affect whether a following IMG gets `loading=lazy`. In particular, `loading=lazy` should still be omitted + * on an IMG following any number of initial IMGs with `fetchpriority=auto` since those initial images may not + * be displayed. + */ + + // Preserve fetchpriority=auto. + $loading_attrs['fetchpriority'] = 'auto'; } if ( null === $maybe_in_viewport ) { @@ -6140,7 +6168,7 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) { * does not include any loop. */ && did_action( 'get_header' ) && ! did_action( 'get_footer' ) - ) { + ) { $maybe_in_viewport = true; $maybe_increase_count = true; } @@ -6149,12 +6177,14 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) { /* * If the element is in the viewport (`true`), potentially add * `fetchpriority` with a value of "high". Otherwise, i.e. if the element - * is not not in the viewport (`false`) or it is unknown (`null`), add - * `loading` with a value of "lazy". + * is not in the viewport (`false`) or it is unknown (`null`), add + * `loading` with a value of "lazy" if the element is not already being + * de-prioritized with `fetchpriority=low` due to occlusion in + * Navigation Overlay, non-initial carousel slides, or a collapsed Details block. */ if ( $maybe_in_viewport ) { $loading_attrs = wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr ); - } else { + } elseif ( ! $is_low_fetchpriority ) { // Only add `loading="lazy"` if the feature is enabled. if ( wp_lazy_loading_enabled( $tag_name, $context ) ) { $loading_attrs['loading'] = 'lazy'; @@ -6164,16 +6194,20 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) { /* * If flag was set based on contextual logic above, increase the content * media count, either unconditionally, or based on whether the image size - * is larger than the threshold. + * is larger than the threshold. This does not apply when the IMG has + * fetchpriority=auto because it may be conditionally displayed by viewport + * size. */ - if ( $increase_count ) { - wp_increase_content_media_count(); - } elseif ( $maybe_increase_count ) { - /** This filter is documented in wp-includes/media.php */ - $wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 ); - - if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) { + if ( 'auto' !== $existing_fetchpriority ) { + if ( $increase_count ) { wp_increase_content_media_count(); + } elseif ( $maybe_increase_count ) { + /** This filter is documented in wp-includes/media.php */ + $wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 ); + + if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) { + wp_increase_content_media_count(); + } } } @@ -6245,12 +6279,13 @@ function wp_increase_content_media_count( $amount = 1 ) { * Determines whether to add `fetchpriority='high'` to loading attributes. * * @since 6.3.0 + * @since 7.0.0 Support is added for IMG tags with `fetchpriority='low'` and `fetchpriority='auto'`. * @access private * - * @param array $loading_attrs Array of the loading optimization attributes for the element. - * @param string $tag_name The tag name. - * @param array $attr Array of the attributes for the element. - * @return array Updated loading optimization attributes for the element. + * @param array $loading_attrs Array of the loading optimization attributes for the element. + * @param string $tag_name The tag name. + * @param array $attr Array of the attributes for the element. + * @return array Updated loading optimization attributes for the element. */ function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr ) { // For now, adding `fetchpriority="high"` is only supported for images. @@ -6258,14 +6293,17 @@ function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr return $loading_attrs; } - if ( isset( $attr['fetchpriority'] ) ) { + $existing_fetchpriority = $attr['fetchpriority'] ?? null; + if ( null !== $existing_fetchpriority && 'auto' !== $existing_fetchpriority ) { /* - * While any `fetchpriority` value could be set in `$loading_attrs`, - * for consistency we only do it for `fetchpriority="high"` since that - * is the only possible value that WordPress core would apply on its - * own. + * When an IMG has been explicitly marked with `fetchpriority=high`, then honor that this is the element that + * should have the priority. In contrast, the Navigation block may add `fetchpriority=low` to an IMG which + * appears in the Navigation Overlay; such images should never be considered candidates for + * `fetchpriority=high`. Lastly, block visibility may add `fetchpriority=auto` to an IMG when the block is + * conditionally displayed based on viewport size. Such an image is considered an LCP element candidate if it + * exceeds the threshold for the minimum number of square pixels. */ - if ( 'high' === $attr['fetchpriority'] ) { + if ( 'high' === $existing_fetchpriority ) { $loading_attrs['fetchpriority'] = 'high'; wp_high_priority_element_flag( false ); } @@ -6292,7 +6330,9 @@ function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr $wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 ); if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) { - $loading_attrs['fetchpriority'] = 'high'; + if ( 'auto' !== $existing_fetchpriority ) { + $loading_attrs['fetchpriority'] = 'high'; + } wp_high_priority_element_flag( false ); } @@ -6306,9 +6346,9 @@ function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr * @access private * * @param bool $value Optional. Used to change the static variable. Default null. - * @return bool Returns true if high-priority element was marked already, otherwise false. + * @return bool Returns true if the high-priority element was not already marked. */ -function wp_high_priority_element_flag( $value = null ) { +function wp_high_priority_element_flag( $value = null ): bool { static $high_priority_element = true; if ( is_bool( $value ) ) { @@ -6371,14 +6411,18 @@ function wp_get_image_editor_output_format( $filename, $mime_type ) { * @return bool Whether client-side media processing is enabled. */ function wp_is_client_side_media_processing_enabled(): bool { + // This is due to SharedArrayBuffer requiring a secure context. + $host = strtolower( (string) strtok( $_SERVER['HTTP_HOST'] ?? '', ':' ) ); + $enabled = ( is_ssl() || 'localhost' === $host || str_ends_with( $host, '.localhost' ) ); + /** * Filters whether client-side media processing is enabled. * * @since 7.0.0 * - * @param bool $enabled Whether client-side media processing is enabled. Default true. + * @param bool $enabled Whether client-side media processing is enabled. Default true if the page is served in a secure context. */ - return (bool) apply_filters( 'wp_client_side_media_processing_enabled', true ); + return (bool) apply_filters( 'wp_client_side_media_processing_enabled', $enabled ); } /** @@ -6391,7 +6435,7 @@ function wp_set_client_side_media_processing_flag(): void { return; } - wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true', 'before' ); + wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true;', 'before' ); $chromium_version = wp_get_chromium_major_version(); @@ -6437,6 +6481,10 @@ function wp_get_chromium_major_version(): ?int { * media processing in the editor. Uses Document-Isolation-Policy * on supported browsers (Chromium 137+). * + * Skips setup when a third-party page builder overrides the block + * editor via a custom `action` query parameter, as DIP would block + * same-origin iframe access that these editors rely on. + * * @since 7.0.0 */ function wp_set_up_cross_origin_isolation(): void { @@ -6454,6 +6502,15 @@ function wp_set_up_cross_origin_isolation(): void { return; } + /* + * Skip when a third-party page builder overrides the block editor. + * DIP isolates the document into its own agent cluster, + * which blocks same-origin iframe access that these editors rely on. + */ + if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) { + return; + } + // Cross-origin isolation is not needed if users can't upload files anyway. if ( ! current_user_can( 'upload_files' ) ) { return; diff --git a/src/wp-includes/ms-blogs.php b/src/wp-includes/ms-blogs.php index f8323dd3b7211..19fc6c1a7613e 100644 --- a/src/wp-includes/ms-blogs.php +++ b/src/wp-includes/ms-blogs.php @@ -755,7 +755,7 @@ function update_archived( $id, $archived ) { * @param int $blog_id Blog ID. * @param string $pref Field name. * @param string $value Field value. - * @param null $deprecated Not used. + * @param mixed $deprecated Not used. * @return string|false $value */ function update_blog_status( $blog_id, $pref, $value, $deprecated = null ) { diff --git a/src/wp-includes/ms-deprecated.php b/src/wp-includes/ms-deprecated.php index 187ebb1565542..fe7e8da494c4a 100644 --- a/src/wp-includes/ms-deprecated.php +++ b/src/wp-includes/ms-deprecated.php @@ -694,6 +694,7 @@ function install_blog_defaults( $blog_id, $user_id ) { * Previously used in core to mark a user as spam or "ham" (not spam) in Multisite. * * @since 3.0.0 + * @since 3.0.2 Deprecated fourth argument. * @deprecated 5.3.0 Use wp_update_user() * @see wp_update_user() * @@ -703,7 +704,7 @@ function install_blog_defaults( $blog_id, $user_id ) { * @param string $pref The column in the wp_users table to update the user's status * in (presumably user_status, spam, or deleted). * @param int $value The new status for the user. - * @param null $deprecated Deprecated as of 3.0.2 and should not be used. + * @param mixed $deprecated Not used. * @return int The initially passed $value. */ function update_user_status( $id, $pref, $value, $deprecated = null ) { diff --git a/src/wp-includes/ms-load.php b/src/wp-includes/ms-load.php index b8d5228d09097..bbc464bdad269 100644 --- a/src/wp-includes/ms-load.php +++ b/src/wp-includes/ms-load.php @@ -383,7 +383,7 @@ function ms_load_current_site_and_network( $domain, $path, $subdomain = false ) // No network has been found, bail. if ( empty( $current_site ) ) { - /** This action is documented in wp-includes/ms-settings.php */ + /** This action is documented in wp-includes/ms-load.php */ do_action( 'ms_network_not_found', $domain, $path ); return false; diff --git a/src/wp-includes/nav-menu.php b/src/wp-includes/nav-menu.php index 0b9d4038ed0d2..ed49892ac0eb6 100644 --- a/src/wp-includes/nav-menu.php +++ b/src/wp-includes/nav-menu.php @@ -511,7 +511,7 @@ function wp_update_nav_menu_item( $menu_id = 0, $menu_item_db_id = 0, $menu_item } } - if ( wp_unslash( $args['menu-item-title'] ) === wp_specialchars_decode( $original_title ) ) { + if ( wp_unslash( $args['menu-item-title'] ) === $original_title ) { $args['menu-item-title'] = ''; } diff --git a/src/wp-includes/php-ai-client/src/AiClient.php b/src/wp-includes/php-ai-client/src/AiClient.php index d554bd5264fae..39e226b4df75a 100644 --- a/src/wp-includes/php-ai-client/src/AiClient.php +++ b/src/wp-includes/php-ai-client/src/AiClient.php @@ -84,7 +84,7 @@ class AiClient /** * @var string The version of the AI Client. */ - public const VERSION = '1.2.1'; + public const VERSION = '1.3.0'; /** * @var ProviderRegistry|null The default provider registry instance. */ @@ -314,6 +314,26 @@ public static function generateSpeechResult($prompt, $modelOrConfig = null, ?Pro self::validateModelOrConfigParameter($modelOrConfig); return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult(); } + /** + * Generates a video using the traditional API approach. + * + * @since 1.3.0 + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function generateVideoResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult + { + self::validateModelOrConfigParameter($modelOrConfig); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateVideoResult(); + } /** * Creates a new message builder for fluent API usage. * diff --git a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php index 8a9fb1dd99502..87b0fedd4f696 100644 --- a/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php +++ b/src/wp-includes/php-ai-client/src/Builders/PromptBuilder.php @@ -10,6 +10,7 @@ use WordPress\AiClient\Events\BeforeGenerateResultEvent; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; +use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; @@ -26,6 +27,7 @@ use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; +use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; @@ -398,7 +400,7 @@ public function usingTopK(int $topK): self */ public function usingStopSequences(string ...$stopSequences): self { - $this->modelConfig->setCustomOption('stopSequences', $stopSequences); + $this->modelConfig->setStopSequences($stopSequences); return $this; } /** @@ -552,6 +554,48 @@ public function asOutputFileType(FileTypeEnum $fileType): self $this->modelConfig->setOutputFileType($fileType); return $this; } + /** + * Sets the output media orientation. + * + * @since 1.3.0 + * + * @param MediaOrientationEnum $orientation The output media orientation. + * @return self + */ + public function asOutputMediaOrientation(MediaOrientationEnum $orientation): self + { + $this->modelConfig->setOutputMediaOrientation($orientation); + return $this; + } + /** + * Sets the output media aspect ratio. + * + * If set, this supersedes the output media orientation, as it is a more + * specific configuration. + * + * @since 1.3.0 + * + * @param string $aspectRatio The aspect ratio (e.g. "16:9", "3:2"). + * @return self + */ + public function asOutputMediaAspectRatio(string $aspectRatio): self + { + $this->modelConfig->setOutputMediaAspectRatio($aspectRatio); + return $this; + } + /** + * Sets the output speech voice. + * + * @since 1.3.0 + * + * @param string $voice The output speech voice. + * @return self + */ + public function asOutputSpeechVoice(string $voice): self + { + $this->modelConfig->setOutputSpeechVoice($voice); + return $this; + } /** * Configures the prompt for JSON response output. * @@ -627,6 +671,9 @@ private function inferCapabilityFromModelInterfaces(ModelInterface $model): ?Cap if ($model instanceof SpeechGenerationModelInterface) { return CapabilityEnum::speechGeneration(); } + if ($model instanceof VideoGenerationModelInterface) { + return CapabilityEnum::videoGeneration(); + } // No supported interface found return null; } @@ -825,9 +872,11 @@ private function executeModelGeneration(ModelInterface $model, CapabilityEnum $c } return $model->generateSpeechResult($messages); } - // Video generation is not yet implemented if ($capability->isVideoGeneration()) { - throw new RuntimeException('Output modality "video" is not yet supported.'); + if (!$model instanceof VideoGenerationModelInterface) { + throw new RuntimeException(sprintf('Model "%s" does not support video generation.', $model->metadata()->getId())); + } + return $model->generateVideoResult($messages); } // TODO: Add support for other capabilities when interfaces are available throw new RuntimeException(sprintf('Capability "%s" is not yet supported for generation.', $capability->value)); @@ -896,6 +945,22 @@ public function convertTextToSpeechResult(): GenerativeAiResult // Generate and return the result with text-to-speech conversion capability return $this->generateResult(CapabilityEnum::textToSpeechConversion()); } + /** + * Generates a video result from the prompt. + * + * @since 1.3.0 + * + * @return GenerativeAiResult The generated result containing video candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support video generation. + */ + public function generateVideoResult(): GenerativeAiResult + { + // Include video in output modalities + $this->includeOutputModalities(ModalityEnum::video()); + // Generate and return the result with video generation capability + return $this->generateResult(CapabilityEnum::videoGeneration()); + } /** * Generates text from the prompt. * @@ -1015,6 +1080,36 @@ public function generateSpeeches(?int $candidateCount = null): array } return $this->generateSpeechResult()->toFiles(); } + /** + * Generates a video from the prompt. + * + * @since 1.3.0 + * + * @return File The generated video file. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no video is generated. + */ + public function generateVideo(): File + { + return $this->generateVideoResult()->toFile(); + } + /** + * Generates multiple videos from the prompt. + * + * @since 1.3.0 + * + * @param int|null $candidateCount The number of videos to generate. + * @return list The generated video files. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no videos are generated. + */ + public function generateVideos(?int $candidateCount = null): array + { + if ($candidateCount !== null) { + $this->usingCandidateCount($candidateCount); + } + return $this->generateVideoResult()->toFiles(); + } /** * Appends a MessagePart to the messages array. * diff --git a/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php b/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php index bbc858d76c4b4..264b2c5b37942 100644 --- a/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php +++ b/src/wp-includes/php-ai-client/src/Messages/DTO/MessagePart.php @@ -26,6 +26,7 @@ * @phpstan-type MessagePartArrayShape array{ * channel: string, * type: string, + * thoughtSignature?: string, * text?: string, * file?: FileArrayShape, * functionCall?: FunctionCallArrayShape, @@ -38,6 +39,7 @@ class MessagePart extends AbstractDataTransferObject { public const KEY_CHANNEL = 'channel'; public const KEY_TYPE = 'type'; + public const KEY_THOUGHT_SIGNATURE = 'thoughtSignature'; public const KEY_TEXT = 'text'; public const KEY_FILE = 'file'; public const KEY_FUNCTION_CALL = 'functionCall'; @@ -50,6 +52,10 @@ class MessagePart extends AbstractDataTransferObject * @var MessagePartTypeEnum The type of this message part. */ private MessagePartTypeEnum $type; + /** + * @var string|null Thought signature for extended thinking. + */ + private ?string $thoughtSignature = null; /** * @var string|null Text content (when type is TEXT). */ @@ -73,11 +79,13 @@ class MessagePart extends AbstractDataTransferObject * * @param mixed $content The content of this message part. * @param MessagePartChannelEnum|null $channel The channel this part belongs to. Defaults to CONTENT. + * @param string|null $thoughtSignature Optional thought signature for extended thinking. * @throws InvalidArgumentException If an unsupported content type is provided. */ - public function __construct($content, ?MessagePartChannelEnum $channel = null) + public function __construct($content, ?MessagePartChannelEnum $channel = null, ?string $thoughtSignature = null) { $this->channel = $channel ?? MessagePartChannelEnum::content(); + $this->thoughtSignature = $thoughtSignature; if (is_string($content)) { $this->type = MessagePartTypeEnum::text(); $this->text = $content; @@ -117,6 +125,17 @@ public function getType(): MessagePartTypeEnum { return $this->type; } + /** + * Gets the thought signature. + * + * @since 1.3.0 + * + * @return string|null The thought signature or null if not set. + */ + public function getThoughtSignature(): ?string + { + return $this->thoughtSignature; + } /** * Gets the text content. * @@ -169,7 +188,8 @@ public function getFunctionResponse(): ?FunctionResponse public static function getJsonSchema(): array { $channelSchema = ['type' => 'string', 'enum' => MessagePartChannelEnum::getValues(), 'description' => 'The channel this message part belongs to.']; - return ['oneOf' => [['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::text()->value], self::KEY_TEXT => ['type' => 'string', 'description' => 'Text content.']], 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::file()->value], self::KEY_FILE => File::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionCall()->value], self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionResponse()->value], self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema()], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => \false]]]; + $thoughtSignatureSchema = ['type' => 'string', 'description' => 'Thought signature for extended thinking.']; + return ['oneOf' => [['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::text()->value], self::KEY_TEXT => ['type' => 'string', 'description' => 'Text content.'], self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::file()->value], self::KEY_FILE => File::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionCall()->value], self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionResponse()->value], self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => \false]]]; } /** * {@inheritDoc} @@ -192,6 +212,9 @@ public function toArray(): array } else { throw new RuntimeException('MessagePart requires one of: text, file, functionCall, or functionResponse. ' . 'This should not be a possible condition.'); } + if ($this->thoughtSignature !== null) { + $data[self::KEY_THOUGHT_SIGNATURE] = $this->thoughtSignature; + } return $data; } /** @@ -206,15 +229,16 @@ public static function fromArray(array $array): self } else { $channel = null; } + $thoughtSignature = $array[self::KEY_THOUGHT_SIGNATURE] ?? null; // Check which properties are set to determine how to construct the MessagePart if (isset($array[self::KEY_TEXT])) { - return new self($array[self::KEY_TEXT], $channel); + return new self($array[self::KEY_TEXT], $channel, $thoughtSignature); } elseif (isset($array[self::KEY_FILE])) { - return new self(File::fromArray($array[self::KEY_FILE]), $channel); + return new self(File::fromArray($array[self::KEY_FILE]), $channel, $thoughtSignature); } elseif (isset($array[self::KEY_FUNCTION_CALL])) { - return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel); + return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel, $thoughtSignature); } elseif (isset($array[self::KEY_FUNCTION_RESPONSE])) { - return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel); + return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel, $thoughtSignature); } else { throw new InvalidArgumentException('MessagePart requires one of: text, file, functionCall, or functionResponse.'); } diff --git a/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderMetadata.php b/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderMetadata.php index 592a9d3ab6b31..08ca58d22c1ec 100644 --- a/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderMetadata.php +++ b/src/wp-includes/php-ai-client/src/Providers/DTO/ProviderMetadata.php @@ -14,6 +14,7 @@ * * @since 0.1.0 * @since 1.2.0 Added optional description property. + * @since 1.3.0 Added optional logoPath property. * * @phpstan-type ProviderMetadataArrayShape array{ * id: string, @@ -21,7 +22,8 @@ * description?: ?string, * type: string, * credentialsUrl?: ?string, - * authenticationMethod?: ?string + * authenticationMethod?: ?string, + * logoPath?: ?string * } * * @extends AbstractDataTransferObject @@ -34,6 +36,7 @@ class ProviderMetadata extends AbstractDataTransferObject public const KEY_TYPE = 'type'; public const KEY_CREDENTIALS_URL = 'credentialsUrl'; public const KEY_AUTHENTICATION_METHOD = 'authenticationMethod'; + public const KEY_LOGO_PATH = 'logoPath'; /** * @var string The provider's unique identifier. */ @@ -58,11 +61,16 @@ class ProviderMetadata extends AbstractDataTransferObject * @var RequestAuthenticationMethod|null The authentication method. */ protected ?RequestAuthenticationMethod $authenticationMethod; + /** + * @var string|null The full path to the provider's logo image file. + */ + protected ?string $logoPath; /** * Constructor. * * @since 0.1.0 * @since 1.2.0 Added optional $description parameter. + * @since 1.3.0 Added optional $logoPath parameter. * * @param string $id The provider's unique identifier. * @param string $name The provider's display name. @@ -70,8 +78,9 @@ class ProviderMetadata extends AbstractDataTransferObject * @param string|null $credentialsUrl The URL where users can get credentials. * @param RequestAuthenticationMethod|null $authenticationMethod The authentication method. * @param string|null $description The provider's description. + * @param string|null $logoPath The full path to the provider's logo image file. */ - public function __construct(string $id, string $name, ProviderTypeEnum $type, ?string $credentialsUrl = null, ?RequestAuthenticationMethod $authenticationMethod = null, ?string $description = null) + public function __construct(string $id, string $name, ProviderTypeEnum $type, ?string $credentialsUrl = null, ?RequestAuthenticationMethod $authenticationMethod = null, ?string $description = null, ?string $logoPath = null) { $this->id = $id; $this->name = $name; @@ -79,6 +88,7 @@ public function __construct(string $id, string $name, ProviderTypeEnum $type, ?s $this->type = $type; $this->credentialsUrl = $credentialsUrl; $this->authenticationMethod = $authenticationMethod; + $this->logoPath = $logoPath; } /** * Gets the provider's unique identifier. @@ -146,37 +156,51 @@ public function getAuthenticationMethod(): ?RequestAuthenticationMethod { return $this->authenticationMethod; } + /** + * Gets the full path to the provider's logo image file. + * + * @since 1.3.0 + * + * @return string|null The full path to the logo image file. + */ + public function getLogoPath(): ?string + { + return $this->logoPath; + } /** * {@inheritDoc} * * @since 0.1.0 * @since 1.2.0 Added description to schema. + * @since 1.3.0 Added logoPath to schema. */ public static function getJsonSchema(): array { - return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The provider\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The provider\'s display name.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'The provider\'s description.'], self::KEY_TYPE => ['type' => 'string', 'enum' => ProviderTypeEnum::getValues(), 'description' => 'The provider type (cloud, server, or client).'], self::KEY_CREDENTIALS_URL => ['type' => 'string', 'description' => 'The URL where users can get credentials.'], self::KEY_AUTHENTICATION_METHOD => ['type' => ['string', 'null'], 'enum' => array_merge(RequestAuthenticationMethod::getValues(), [null]), 'description' => 'The authentication method.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]]; + return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The provider\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The provider\'s display name.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'The provider\'s description.'], self::KEY_TYPE => ['type' => 'string', 'enum' => ProviderTypeEnum::getValues(), 'description' => 'The provider type (cloud, server, or client).'], self::KEY_CREDENTIALS_URL => ['type' => 'string', 'description' => 'The URL where users can get credentials.'], self::KEY_AUTHENTICATION_METHOD => ['type' => ['string', 'null'], 'enum' => array_merge(RequestAuthenticationMethod::getValues(), [null]), 'description' => 'The authentication method.'], self::KEY_LOGO_PATH => ['type' => 'string', 'description' => 'The full path to the provider\'s logo image file.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]]; } /** * {@inheritDoc} * * @since 0.1.0 * @since 1.2.0 Added description to output. + * @since 1.3.0 Added logoPath to output. * * @return ProviderMetadataArrayShape */ public function toArray(): array { - return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_DESCRIPTION => $this->description, self::KEY_TYPE => $this->type->value, self::KEY_CREDENTIALS_URL => $this->credentialsUrl, self::KEY_AUTHENTICATION_METHOD => $this->authenticationMethod ? $this->authenticationMethod->value : null]; + return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_DESCRIPTION => $this->description, self::KEY_TYPE => $this->type->value, self::KEY_CREDENTIALS_URL => $this->credentialsUrl, self::KEY_AUTHENTICATION_METHOD => $this->authenticationMethod ? $this->authenticationMethod->value : null, self::KEY_LOGO_PATH => $this->logoPath]; } /** * {@inheritDoc} * * @since 0.1.0 * @since 1.2.0 Added description support. + * @since 1.3.0 Added logoPath support. */ public static function fromArray(array $array): self { static::validateFromArrayData($array, [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]); - return new self($array[self::KEY_ID], $array[self::KEY_NAME], ProviderTypeEnum::from($array[self::KEY_TYPE]), $array[self::KEY_CREDENTIALS_URL] ?? null, isset($array[self::KEY_AUTHENTICATION_METHOD]) ? RequestAuthenticationMethod::from($array[self::KEY_AUTHENTICATION_METHOD]) : null, $array[self::KEY_DESCRIPTION] ?? null); + return new self($array[self::KEY_ID], $array[self::KEY_NAME], ProviderTypeEnum::from($array[self::KEY_TYPE]), $array[self::KEY_CREDENTIALS_URL] ?? null, isset($array[self::KEY_AUTHENTICATION_METHOD]) ? RequestAuthenticationMethod::from($array[self::KEY_AUTHENTICATION_METHOD]) : null, $array[self::KEY_DESCRIPTION] ?? null, $array[self::KEY_LOGO_PATH] ?? null); } } diff --git a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ServerException.php b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ServerException.php index 8963674137ccc..248c0a775c98a 100644 --- a/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ServerException.php +++ b/src/wp-includes/php-ai-client/src/Providers/Http/Exception/ServerException.php @@ -30,7 +30,7 @@ class ServerException extends RuntimeException public static function fromServerErrorResponse(Response $response): self { $statusCode = $response->getStatusCode(); - $statusTexts = [500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 507 => 'Insufficient Storage']; + $statusTexts = [500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 507 => 'Insufficient Storage', 529 => 'Overloaded']; if (isset($statusTexts[$statusCode])) { $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode); } else { diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationModelInterface.php new file mode 100644 index 0000000000000..f83b4742e2500 --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the video generation prompt. + * @return GenerativeAiResult Result containing generated videos. + */ + public function generateVideoResult(array $prompt): GenerativeAiResult; +} diff --git a/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationOperationModelInterface.php b/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationOperationModelInterface.php new file mode 100644 index 0000000000000..45177bc602fef --- /dev/null +++ b/src/wp-includes/php-ai-client/src/Providers/Models/VideoGeneration/Contracts/VideoGenerationOperationModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the video generation prompt. + * @return GenerativeAiOperation The initiated video generation operation. + */ + public function generateVideoOperation(array $prompt): GenerativeAiOperation; +} diff --git a/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php b/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php index df3201c92f77d..39265a77a1d2c 100644 --- a/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php +++ b/src/wp-includes/php-ai-client/src/Results/DTO/TokenUsage.php @@ -10,12 +10,16 @@ * This DTO tracks the number of tokens used in prompts and completions, * which is important for monitoring usage and costs. * + * Note that thought tokens are a subset of completion tokens, not additive. + * In other words: completionTokens - thoughtTokens = tokens of actual output content. + * * @since 0.1.0 * * @phpstan-type TokenUsageArrayShape array{ * promptTokens: int, * completionTokens: int, - * totalTokens: int + * totalTokens: int, + * thoughtTokens?: int * } * * @extends AbstractDataTransferObject @@ -25,32 +29,39 @@ class TokenUsage extends AbstractDataTransferObject public const KEY_PROMPT_TOKENS = 'promptTokens'; public const KEY_COMPLETION_TOKENS = 'completionTokens'; public const KEY_TOTAL_TOKENS = 'totalTokens'; + public const KEY_THOUGHT_TOKENS = 'thoughtTokens'; /** * @var int Number of tokens in the prompt. */ private int $promptTokens; /** - * @var int Number of tokens in the completion. + * @var int Number of tokens in the completion, including any thought tokens. */ private int $completionTokens; /** * @var int Total number of tokens used. */ private int $totalTokens; + /** + * @var int|null Number of tokens used for thinking, as a subset of completion tokens. + */ + private ?int $thoughtTokens; /** * Constructor. * * @since 0.1.0 * * @param int $promptTokens Number of tokens in the prompt. - * @param int $completionTokens Number of tokens in the completion. + * @param int $completionTokens Number of tokens in the completion, including any thought tokens. * @param int $totalTokens Total number of tokens used. + * @param int|null $thoughtTokens Number of tokens used for thinking, as a subset of completion tokens. */ - public function __construct(int $promptTokens, int $completionTokens, int $totalTokens) + public function __construct(int $promptTokens, int $completionTokens, int $totalTokens, ?int $thoughtTokens = null) { $this->promptTokens = $promptTokens; $this->completionTokens = $completionTokens; $this->totalTokens = $totalTokens; + $this->thoughtTokens = $thoughtTokens; } /** * Gets the number of prompt tokens. @@ -64,7 +75,7 @@ public function getPromptTokens(): int return $this->promptTokens; } /** - * Gets the number of completion tokens. + * Gets the number of completion tokens, including any thought tokens. * * @since 0.1.0 * @@ -85,6 +96,17 @@ public function getTotalTokens(): int { return $this->totalTokens; } + /** + * Gets the number of thought tokens, which is a subset of the completion token count. + * + * @since 1.3.0 + * + * @return int|null The thought token count or null if not available. + */ + public function getThoughtTokens(): ?int + { + return $this->thoughtTokens; + } /** * {@inheritDoc} * @@ -92,7 +114,7 @@ public function getTotalTokens(): int */ public static function getJsonSchema(): array { - return ['type' => 'object', 'properties' => [self::KEY_PROMPT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the prompt.'], self::KEY_COMPLETION_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the completion.'], self::KEY_TOTAL_TOKENS => ['type' => 'integer', 'description' => 'Total number of tokens used.']], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]]; + return ['type' => 'object', 'properties' => [self::KEY_PROMPT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the prompt.'], self::KEY_COMPLETION_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the completion, including any thought tokens.'], self::KEY_TOTAL_TOKENS => ['type' => 'integer', 'description' => 'Total number of tokens used.'], self::KEY_THOUGHT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens used for thinking, as a subset of completion tokens.']], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]]; } /** * {@inheritDoc} @@ -103,7 +125,11 @@ public static function getJsonSchema(): array */ public function toArray(): array { - return [self::KEY_PROMPT_TOKENS => $this->promptTokens, self::KEY_COMPLETION_TOKENS => $this->completionTokens, self::KEY_TOTAL_TOKENS => $this->totalTokens]; + $data = [self::KEY_PROMPT_TOKENS => $this->promptTokens, self::KEY_COMPLETION_TOKENS => $this->completionTokens, self::KEY_TOTAL_TOKENS => $this->totalTokens]; + if ($this->thoughtTokens !== null) { + $data[self::KEY_THOUGHT_TOKENS] = $this->thoughtTokens; + } + return $data; } /** * {@inheritDoc} @@ -113,6 +139,6 @@ public function toArray(): array public static function fromArray(array $array): self { static::validateFromArrayData($array, [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]); - return new self($array[self::KEY_PROMPT_TOKENS], $array[self::KEY_COMPLETION_TOKENS], $array[self::KEY_TOTAL_TOKENS]); + return new self($array[self::KEY_PROMPT_TOKENS], $array[self::KEY_COMPLETION_TOKENS], $array[self::KEY_TOTAL_TOKENS], $array[self::KEY_THOUGHT_TOKENS] ?? null); } } diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index 03593ea3e64e6..9574ba123b0b7 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -2263,7 +2263,7 @@ function wp_password_change_notification( $user ) { * @since 4.6.0 The `$notify` parameter accepts 'user' for sending notification only to the user created. * * @param int $user_id User ID. - * @param null $deprecated Not used (argument deprecated). + * @param mixed $deprecated Not used. * @param string $notify Optional. Type of notification that should happen. Accepts 'admin' or an empty * string (admin only), 'user', or 'both' (admin and user). Default empty. */ diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 521b0f4a81e04..704a990298826 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -1383,16 +1383,16 @@ public function get_index( $request ) { $input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' ); $output_formats = array(); foreach ( $input_formats as $mime_type ) { - /** This filter is documented in wp-includes/class-wp-image-editor.php */ + /** This filter is documented in wp-includes/media.php */ $output_formats = apply_filters( 'image_editor_output_format', $output_formats, '', $mime_type ); } $available['image_output_formats'] = (object) $output_formats; - /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */ $available['jpeg_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/jpeg' ); - /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */ $available['png_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/png' ); - /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */ $available['gif_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/gif' ); } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 574d0e92533a9..cb714d5a5de71 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -103,6 +103,26 @@ public function register_routes() { 'schema' => array( $this, 'get_public_item_schema' ), ) ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/finalize', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'finalize_item' ), + 'permission_callback' => array( $this, 'edit_media_item_permissions_check' ), + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the attachment.' ), + 'type' => 'integer', + ), + ), + ), + 'allow_batch' => $this->allow_batch, + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); } } @@ -232,6 +252,12 @@ public function create_item_permissions_check( $request ) { */ $prevent_unsupported_uploads = apply_filters( 'wp_prevent_unsupported_mime_type_uploads', true, $files['file']['type'] ?? null ); + // When the client handles image processing (generate_sub_sizes is false), + // skip the server-side image editor support check. + if ( false === $request['generate_sub_sizes'] ) { + $prevent_unsupported_uploads = false; + } + // If the upload is an image, check if the server can handle the mime type. if ( $prevent_unsupported_uploads && @@ -278,7 +304,7 @@ public function create_item( $request ) { } // Handle generate_sub_sizes parameter. - if ( isset( $request['generate_sub_sizes'] ) && ! $request['generate_sub_sizes'] ) { + if ( false === $request['generate_sub_sizes'] ) { add_filter( 'intermediate_image_sizes_advanced', '__return_empty_array', 100 ); add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array', 100 ); // Disable server-side EXIF rotation so the client can handle it. @@ -2185,4 +2211,48 @@ private static function filter_wp_unique_filename( $filename, $dir, $number, $at return $filename; } + + /** + * Finalizes an attachment after client-side media processing. + * + * Triggers the 'wp_generate_attachment_metadata' filter so that + * server-side plugins can process the attachment after all client-side + * operations (upload, thumbnail generation, sideloads) are complete. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. + */ + public function finalize_item( WP_REST_Request $request ) { + $attachment_id = $request['id']; + + $post = $this->get_post( $attachment_id ); + if ( is_wp_error( $post ) ) { + return $post; + } + + $metadata = wp_get_attachment_metadata( $attachment_id ); + if ( ! is_array( $metadata ) ) { + $metadata = array(); + } + + /** This filter is documented in wp-admin/includes/image.php */ + $metadata = apply_filters( 'wp_generate_attachment_metadata', $metadata, $attachment_id, 'update' ); + + wp_update_attachment_metadata( $attachment_id, $metadata ); + + $response_request = new WP_REST_Request( + WP_REST_Server::READABLE, + rest_get_route_for_post( $attachment_id ) + ); + + $response_request['context'] = 'edit'; + + if ( isset( $request['_fields'] ) ) { + $response_request['_fields'] = $request['_fields']; + } + + return $this->prepare_item_for_response( $post, $response_request ); + } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index 36839d9c72bbf..9db45cadd0dbd 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -436,7 +436,7 @@ public function create_post_autosave( $post_data, array $meta = array() ) { $new_autosave['ID'] = $old_autosave->ID; $new_autosave['post_author'] = $user_id; - /** This filter is documented in wp-admin/post.php */ + /** This action is documented in wp-admin/includes/post.php */ do_action( 'wp_creating_autosave', $new_autosave ); // wp_update_post() expects escaped array. diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index 3f83504f8a3e5..f462928847c77 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -560,6 +560,14 @@ public function create_item_permissions_check( $request ) { } } + if ( $is_note && ! empty( $request['post'] ) && ! current_user_can( 'edit_post', (int) $request['post'] ) ) { + return new WP_Error( + 'rest_cannot_create_note', + __( 'Sorry, you are not allowed to create notes for this post.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + $edit_cap = $is_note ? array( 'edit_post', (int) $request['post'] ) : array( 'moderate_comments' ); if ( isset( $request['status'] ) && ! current_user_can( ...$edit_cap ) ) { return new WP_Error( diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index 8e343d2447141..0ab54a3a0d384 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -2294,7 +2294,7 @@ protected function prepare_links( $post ) { // If we have a featured media, add that. $featured_media = get_post_thumbnail_id( $post->ID ); - if ( $featured_media ) { + if ( $featured_media && ( 'publish' === get_post_status( $featured_media ) || current_user_can( 'read_post', $featured_media ) ) ) { $image_url = rest_url( rest_get_route_for_post( $featured_media ) ); $links['https://api.w.org/featuredmedia'] = array( diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 99282e6d3e986..73a888d6eac48 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -226,6 +226,8 @@ protected function get_revision( $id ) { * * @since 4.7.0 * + * @see WP_REST_Posts_Controller::get_items() + * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ @@ -297,7 +299,17 @@ public function get_items( $request ) { $args['update_post_meta_cache'] = false; } - /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */ + /** + * Filters WP_Query arguments when querying revisions via the REST API. + * + * Serves the same purpose as the {@see 'rest_{$this->post_type}_query'} filter in + * WP_REST_Posts_Controller, but for the standalone WP_REST_Revisions_Controller. + * + * @since 5.0.0 + * + * @param array $args Array of arguments for WP_Query. + * @param WP_REST_Request $request The REST API request. + */ $args = apply_filters( 'rest_revision_query', $args, $request ); if ( ! is_array( $args ) ) { $args = array(); @@ -639,35 +651,46 @@ public function prepare_item_for_response( $item, $request ) { $data['slug'] = $post->post_name; } - if ( in_array( 'guid', $fields, true ) ) { - $data['guid'] = array( - /** This filter is documented in wp-includes/post-template.php */ - 'rendered' => apply_filters( 'get_the_guid', $post->guid, $post->ID ), - 'raw' => $post->guid, - ); + if ( rest_is_field_included( 'guid', $fields ) ) { + $data['guid'] = array(); } - - if ( in_array( 'title', $fields, true ) ) { - $data['title'] = array( - 'raw' => $post->post_title, - 'rendered' => get_the_title( $post->ID ), - ); + if ( rest_is_field_included( 'guid.rendered', $fields ) ) { + /** This filter is documented in wp-includes/post-template.php */ + $data['guid']['rendered'] = apply_filters( 'get_the_guid', $post->guid, $post->ID ); + } + if ( rest_is_field_included( 'guid.raw', $fields ) ) { + $data['guid']['raw'] = $post->guid; } - if ( in_array( 'content', $fields, true ) ) { + if ( rest_is_field_included( 'title', $fields ) ) { + $data['title'] = array(); + } + if ( rest_is_field_included( 'title.raw', $fields ) ) { + $data['title']['raw'] = $post->post_title; + } + if ( rest_is_field_included( 'title.rendered', $fields ) ) { + $data['title']['rendered'] = get_the_title( $post->ID ); + } - $data['content'] = array( - 'raw' => $post->post_content, - /** This filter is documented in wp-includes/post-template.php */ - 'rendered' => apply_filters( 'the_content', $post->post_content ), - ); + if ( rest_is_field_included( 'content', $fields ) ) { + $data['content'] = array(); + } + if ( rest_is_field_included( 'content.raw', $fields ) ) { + $data['content']['raw'] = $post->post_content; + } + if ( rest_is_field_included( 'content.rendered', $fields ) ) { + /** This filter is documented in wp-includes/post-template.php */ + $data['content']['rendered'] = apply_filters( 'the_content', $post->post_content ); } - if ( in_array( 'excerpt', $fields, true ) ) { - $data['excerpt'] = array( - 'raw' => $post->post_excerpt, - 'rendered' => $this->prepare_excerpt_response( $post->post_excerpt, $post ), - ); + if ( rest_is_field_included( 'excerpt', $fields ) ) { + $data['excerpt'] = array(); + } + if ( rest_is_field_included( 'excerpt.raw', $fields ) ) { + $data['excerpt']['raw'] = $post->post_excerpt; + } + if ( rest_is_field_included( 'excerpt.rendered', $fields ) ) { + $data['excerpt']['rendered'] = $this->prepare_excerpt_response( $post->post_excerpt, $post ); } if ( rest_is_field_included( 'meta', $fields ) ) { diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index f9ea36720baea..733914d1d3656 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2551,21 +2551,42 @@ function wp_enqueue_global_styles() { $is_block_theme = wp_is_block_theme(); $is_classic_theme = ! $is_block_theme; - /* - * Global styles should be printed in the head for block themes, or for classic themes when loading assets on - * demand is disabled, which is the default. - * The footer should only be used for classic themes when loading assets on demand is enabled. + /** + * Global styles should be printed in the HEAD for block themes, or for classic themes when loading assets on + * demand is disabled (which is no longer the default since WordPress 6.9). * - * See https://core.trac.wordpress.org/ticket/53494 and https://core.trac.wordpress.org/ticket/61965. + * @link https://core.trac.wordpress.org/ticket/53494 + * @link https://core.trac.wordpress.org/ticket/61965 */ if ( - ( $is_block_theme && doing_action( 'wp_footer' ) ) || - ( $is_classic_theme && doing_action( 'wp_footer' ) && ! $assets_on_demand ) || - ( $is_classic_theme && doing_action( 'wp_enqueue_scripts' ) && $assets_on_demand ) + doing_action( 'wp_footer' ) && + ( + $is_block_theme || + ( $is_classic_theme && ! $assets_on_demand ) + ) ) { return; } + /** + * The footer should only be used for classic themes when loading assets on demand is enabled. In WP 6.9 this is the + * default with the introduction of hoisting late-printed styles (via {@see wp_load_classic_theme_block_styles_on_demand()}). + * So even though the main global styles are not printed here in the HEAD for classic themes with on-demand asset + * loading, a placeholder for the global styles is still enqueued. Then when {@see wp_hoist_late_printed_styles()} + * processes the output buffer, it can locate the placeholder and inject the global styles from the footer into the + * HEAD, replacing the placeholder. + * + * @link https://core.trac.wordpress.org/ticket/64099 + */ + if ( $is_classic_theme && doing_action( 'wp_enqueue_scripts' ) && $assets_on_demand ) { + if ( has_action( 'wp_template_enhancement_output_buffer_started', 'wp_hoist_late_printed_styles' ) ) { + wp_register_style( 'wp-global-styles-placeholder', false ); + wp_add_inline_style( 'wp-global-styles-placeholder', ':root { --wp-internal-comment: "Placeholder for wp_hoist_late_printed_styles() to replace with the global-styles printed at wp_footer." }' ); + wp_enqueue_style( 'wp-global-styles-placeholder' ); + } + return; + } + /* * If loading the CSS for each block separately, then load the theme.json CSS conditionally. * This removes the CSS from the global-styles stylesheet and adds it to the inline CSS for each block. @@ -2741,6 +2762,16 @@ function wp_should_load_block_assets_on_demand() { */ function wp_enqueue_registered_block_scripts_and_styles() { if ( wp_should_load_block_assets_on_demand() ) { + /** + * Add placeholder for where block styles would historically get enqueued in a classic theme when block assets + * are not loaded on demand. This happens right after {@see wp_common_block_scripts_and_styles()} is called + * at which time wp-block-library is enqueued. + */ + if ( ! wp_is_block_theme() && has_action( 'wp_template_enhancement_output_buffer_started', 'wp_hoist_late_printed_styles' ) ) { + wp_register_style( 'wp-block-styles-placeholder', false ); + wp_add_inline_style( 'wp-block-styles-placeholder', ':root { --wp-internal-comment: "Placeholder for wp_hoist_late_printed_styles() to replace with the block styles printed at wp_footer." }' ); + wp_enqueue_style( 'wp-block-styles-placeholder' ); + } return; } @@ -3643,11 +3674,15 @@ function wp_remove_surrounding_empty_script_tags( $contents ) { /** * Adds hooks to load block styles on demand in classic themes. * + * This function must be called before {@see wp_default_styles()} and {@see register_core_block_style_handles()} so that + * the filters are added to cause {@see wp_should_load_separate_core_block_assets()} to return true. + * * @since 6.9.0 + * @since 7.0.0 This is now invoked at the `wp_default_styles` action with priority 0 instead of at `init` with priority 8. * * @see _add_default_theme_supports() */ -function wp_load_classic_theme_block_styles_on_demand() { +function wp_load_classic_theme_block_styles_on_demand(): void { // This is not relevant to block themes, as they are opted in to loading separate styles on demand via _add_default_theme_supports(). if ( wp_is_block_theme() ) { return; @@ -3700,42 +3735,30 @@ function wp_load_classic_theme_block_styles_on_demand() { * @see wp_load_classic_theme_block_styles_on_demand() * @see _wp_footer_scripts() */ -function wp_hoist_late_printed_styles() { +function wp_hoist_late_printed_styles(): void { // Skip the embed template on-demand styles aren't relevant, and there is no wp_head action. if ( is_embed() ) { return; } - // Capture the styles enqueued at the enqueue_block_assets action, so that non-core block styles and global styles can be inserted afterwards during hoisting. - $style_handles_at_enqueue_block_assets = array(); - add_action( - 'enqueue_block_assets', - static function () use ( &$style_handles_at_enqueue_block_assets ) { - $style_handles_at_enqueue_block_assets = wp_styles()->queue; - }, - PHP_INT_MIN - ); - add_action( - 'enqueue_block_assets', - static function () use ( &$style_handles_at_enqueue_block_assets ) { - $style_handles_at_enqueue_block_assets = array_values( array_diff( wp_styles()->queue, $style_handles_at_enqueue_block_assets ) ); - }, - PHP_INT_MAX - ); - /* * Add a placeholder comment into the inline styles for wp-block-library, after which the late block styles * can be hoisted from the footer to be printed in the header by means of a filter below on the template enhancement - * output buffer. The `wp_print_styles` action is used to ensure that if the inline style gets replaced at - * `enqueue_block_assets` or `wp_enqueue_scripts` that the placeholder will be sure to be present. + * output buffer. + * + * Note that wp_maybe_inline_styles() prepends the inlined style to the extra 'after' array, which happens after + * this code runs. This ensures that the placeholder appears right after any inlined wp-block-library styles, + * which would be common.css. */ $placeholder = sprintf( '/*%s*/', uniqid( 'wp_block_styles_on_demand_placeholder:' ) ); - add_action( - 'wp_print_styles', - static function () use ( $placeholder ) { + $dependency = wp_styles()->query( 'wp-block-library', 'registered' ); + if ( $dependency ) { + if ( ! isset( $dependency->extra['after'] ) ) { wp_add_inline_style( 'wp-block-library', $placeholder ); + } else { + array_unshift( $dependency->extra['after'], $placeholder ); } - ); + } /* * Create a substitute for `print_late_styles()` which is aware of block styles. This substitute does not print @@ -3765,29 +3788,29 @@ static function () use ( $placeholder ) { } /* - * First print all styles related to blocks which should be inserted right after the wp-block-library stylesheet + * First print all styles related to core blocks which should be inserted right after the wp-block-library stylesheet * to preserve the CSS cascade. The logic in this `if` statement is derived from `wp_print_styles()`. */ $enqueued_core_block_styles = array_values( array_intersect( $all_core_block_style_handles, wp_styles()->queue ) ); if ( count( $enqueued_core_block_styles ) > 0 ) { ob_start(); wp_styles()->do_items( $enqueued_core_block_styles ); - $printed_core_block_styles = ob_get_clean(); + $printed_core_block_styles = (string) ob_get_clean(); } - // Non-core block styles get printed after the classic-theme-styles stylesheet. + // Capture non-core block styles so they can get printed at the point where wp_enqueue_registered_block_scripts_and_styles() runs. $enqueued_other_block_styles = array_values( array_intersect( $all_other_block_style_handles, wp_styles()->queue ) ); if ( count( $enqueued_other_block_styles ) > 0 ) { ob_start(); wp_styles()->do_items( $enqueued_other_block_styles ); - $printed_other_block_styles = ob_get_clean(); + $printed_other_block_styles = (string) ob_get_clean(); } - // Capture the global-styles so that it can be printed separately after classic-theme-styles and other styles enqueued at enqueue_block_assets. + // Capture the global-styles so that it can be printed at the point where wp_enqueue_global_styles() runs. if ( wp_style_is( 'global-styles' ) ) { ob_start(); wp_styles()->do_items( array( 'global-styles' ) ); - $printed_global_styles = ob_get_clean(); + $printed_global_styles = (string) ob_get_clean(); } /* @@ -3797,7 +3820,7 @@ static function () use ( $placeholder ) { */ ob_start(); wp_styles()->do_footer_items(); - $printed_late_styles = ob_get_clean(); + $printed_late_styles = (string) ob_get_clean(); }; /* @@ -3828,7 +3851,7 @@ static function () use ( $capture_late_styles ) { // Replace placeholder with the captured late styles. add_filter( 'wp_template_enhancement_output_buffer', - static function ( $buffer ) use ( $placeholder, &$style_handles_at_enqueue_block_assets, &$printed_core_block_styles, &$printed_other_block_styles, &$printed_global_styles, &$printed_late_styles ) { + static function ( $buffer ) use ( $placeholder, &$printed_core_block_styles, &$printed_other_block_styles, &$printed_global_styles, &$printed_late_styles ) { // Anonymous subclass of WP_HTML_Tag_Processor which exposes underlying bookmark spans. $processor = new class( $buffer ) extends WP_HTML_Tag_Processor { @@ -3848,7 +3871,7 @@ private function get_span(): WP_HTML_Span { * * @param string $text Text to insert. */ - public function insert_before( string $text ) { + public function insert_before( string $text ): void { $this->lexical_updates[] = new WP_HTML_Text_Replacement( $this->get_span()->start, 0, $text ); } @@ -3857,7 +3880,7 @@ public function insert_before( string $text ) { * * @param string $text Text to insert. */ - public function insert_after( string $text ) { + public function insert_after( string $text ): void { $span = $this->get_span(); $this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start + $span->length, 0, $text ); @@ -3866,47 +3889,60 @@ public function insert_after( string $text ) { /** * Removes the current token. */ - public function remove() { + public function remove(): void { $span = $this->get_span(); $this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start, $span->length, '' ); } + + /** + * Replaces the current token. + * + * @param string $text Text to replace with. + */ + public function replace( string $text ): void { + $span = $this->get_span(); + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $span->start, $span->length, $text ); + } }; // Locate the insertion points in the HEAD. while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) { if ( + 'STYLE' === $processor->get_tag() && + 'wp-global-styles-placeholder-inline-css' === $processor->get_attribute( 'id' ) + ) { + /** This is added in {@see wp_enqueue_global_styles()} */ + $processor->set_bookmark( 'wp_global_styles_placeholder' ); + } elseif ( + 'STYLE' === $processor->get_tag() && + 'wp-block-styles-placeholder-inline-css' === $processor->get_attribute( 'id' ) + ) { + /** This is added in {@see wp_enqueue_registered_block_scripts_and_styles()} */ + $processor->set_bookmark( 'wp_block_styles_placeholder' ); + } elseif ( 'STYLE' === $processor->get_tag() && 'wp-block-library-inline-css' === $processor->get_attribute( 'id' ) ) { + /** This is added here in {@see wp_hoist_late_printed_styles()} */ $processor->set_bookmark( 'wp_block_library' ); } elseif ( 'HEAD' === $processor->get_tag() && $processor->is_tag_closer() ) { $processor->set_bookmark( 'head_end' ); break; - } elseif ( ( 'STYLE' === $processor->get_tag() || 'LINK' === $processor->get_tag() ) && $processor->get_attribute( 'id' ) ) { - $id = $processor->get_attribute( 'id' ); - $handle = null; - if ( 'STYLE' === $processor->get_tag() ) { - if ( preg_match( '/^(.+)-inline-css$/', $id, $matches ) ) { - $handle = $matches[1]; - } - } elseif ( preg_match( '/^(.+)-css$/', $id, $matches ) ) { - $handle = $matches[1]; - } - - if ( 'classic-theme-styles' === $handle ) { - $processor->set_bookmark( 'classic_theme_styles' ); - } - - if ( $handle && in_array( $handle, $style_handles_at_enqueue_block_assets, true ) ) { - if ( ! $processor->has_bookmark( 'first_style_at_enqueue_block_assets' ) ) { - $processor->set_bookmark( 'first_style_at_enqueue_block_assets' ); - } - $processor->set_bookmark( 'last_style_at_enqueue_block_assets' ); - } } } + /** + * Replace the placeholder for global styles enqueued during {@see wp_enqueue_global_styles()}. This is done + * even if $printed_global_styles is empty. + */ + if ( $processor->has_bookmark( 'wp_global_styles_placeholder' ) ) { + $processor->seek( 'wp_global_styles_placeholder' ); + $processor->replace( $printed_global_styles ); + $printed_global_styles = ''; + } + /* * Insert block styles right after wp-block-library (if it is present). The placeholder CSS comment will * always be added to the wp-block-library inline style since it gets printed at `wp_head` before the blocks @@ -3921,13 +3957,34 @@ public function remove() { $css_text = $processor->get_modifiable_text(); /* - * A placeholder CSS comment is added to the inline style in order to force an inline STYLE tag to - * be printed. Now that we've located the inline style, the placeholder comment can be removed. If - * there is no CSS left in the STYLE tag after removing the placeholder (aside from the sourceURL - * comment), then remove the STYLE entirely. + * Split the block library inline style by the placeholder to identify the original inlined CSS, which + * likely would be common.css, followed by any inline styles which had been added by the theme or + * plugins via `wp_add_inline_style( 'wp-block-library', '...' )`. The separate block styles loaded on + * demand will get inserted after the inlined common.css and before the extra inline styles added by the + * user. + */ + $css_text_around_placeholder = explode( $placeholder, $css_text, 2 ); + $extra_inline_styles = ''; + if ( count( $css_text_around_placeholder ) === 2 ) { + $css_text = $css_text_around_placeholder[0]; + if ( '' !== trim( $css_text ) ) { + $inlined_src = wp_styles()->get_data( 'wp-block-library', 'inlined_src' ); + if ( $inlined_src ) { + $css_text .= sprintf( + "\n/*# sourceURL=%s */\n", + esc_url_raw( $inlined_src ) + ); + } + } + $extra_inline_styles = $css_text_around_placeholder[1]; + } + + /* + * The placeholder CSS comment was added to the inline style in order to force an inline STYLE tag to + * be printed. Now that the inline style has been located and the placeholder comment has been removed, if + * there is no CSS left in the STYLE tag after removal, then remove the STYLE tag entirely. */ - $css_text = str_replace( $placeholder, '', $css_text ); - if ( preg_match( ':^/\*# sourceURL=\S+? \*/$:', trim( $css_text ) ) ) { + if ( '' === trim( $css_text ) ) { $processor->remove(); } else { $processor->set_modifiable_text( $css_text ); @@ -3936,20 +3993,18 @@ public function remove() { $inserted_after = $printed_core_block_styles; $printed_core_block_styles = ''; - // If the classic-theme-styles is absent, then the third-party block styles cannot be inserted after it, so they get inserted here. - if ( ! $processor->has_bookmark( 'classic_theme_styles' ) ) { - if ( '' !== $printed_other_block_styles ) { - $inserted_after .= $printed_other_block_styles; - } - $printed_other_block_styles = ''; - - // If there aren't any other styles printed at enqueue_block_assets either, then the global styles need to also be printed here. - if ( ! $processor->has_bookmark( 'last_style_at_enqueue_block_assets' ) ) { - if ( '' !== $printed_global_styles ) { - $inserted_after .= $printed_global_styles; - } - $printed_global_styles = ''; - } + /* + * Add a new inline style for any user styles added via wp_add_inline_style( 'wp-block-library', '...' ). + * This must be added here after $printed_core_block_styles to preserve the original CSS cascade when + * the combined block library stylesheet was used. The pattern here is checking to see if it is not just + * a sourceURL comment after the placeholder above is removed. + */ + if ( ! preg_match( ':^\s*(/\*# sourceURL=\S+? \*/\s*)?$:s', $extra_inline_styles ) ) { + $style_processor = new WP_HTML_Tag_Processor( '' ); + $style_processor->next_tag(); + $style_processor->set_attribute( 'id', 'wp-block-library-inline-css-extra' ); + $style_processor->set_modifiable_text( $extra_inline_styles ); + $inserted_after .= "{$style_processor->get_updated_html()}\n"; } if ( '' !== $inserted_after ) { @@ -3957,23 +4012,14 @@ public function remove() { } } - // Insert global-styles after the styles enqueued at enqueue_block_assets. - if ( '' !== $printed_global_styles && $processor->has_bookmark( 'last_style_at_enqueue_block_assets' ) ) { - $processor->seek( 'last_style_at_enqueue_block_assets' ); - - $processor->insert_after( "\n" . $printed_global_styles ); - $printed_global_styles = ''; - - if ( ! $processor->has_bookmark( 'classic_theme_styles' ) && '' !== $printed_other_block_styles ) { - $processor->insert_after( "\n" . $printed_other_block_styles ); - $printed_other_block_styles = ''; + // Insert block styles at the point where wp_enqueue_registered_block_scripts_and_styles() normally enqueues styles. + if ( $processor->has_bookmark( 'wp_block_styles_placeholder' ) ) { + $processor->seek( 'wp_block_styles_placeholder' ); + if ( '' !== $printed_other_block_styles ) { + $processor->replace( "\n" . $printed_other_block_styles ); + } else { + $processor->remove(); } - } - - // Insert third-party block styles right after the classic-theme-styles. - if ( '' !== $printed_other_block_styles && $processor->has_bookmark( 'classic_theme_styles' ) ) { - $processor->seek( 'classic_theme_styles' ); - $processor->insert_after( "\n" . $printed_other_block_styles ); $printed_other_block_styles = ''; } diff --git a/src/wp-includes/template-loader.php b/src/wp-includes/template-loader.php index ffd2567524622..b3183590398b7 100644 --- a/src/wp-includes/template-loader.php +++ b/src/wp-includes/template-loader.php @@ -111,8 +111,15 @@ * * @param string $template The path of the template to include. */ - $template = apply_filters( 'template_include', $template ); - if ( $template ) { + $template = apply_filters( 'template_include', $template ); + $is_stringy = is_string( $template ) || ( is_object( $template ) && method_exists( $template, '__toString' ) ); + $template = $is_stringy ? realpath( (string) $template ) : null; + if ( + is_string( $template ) && + ( str_ends_with( $template, '.php' ) || str_ends_with( $template, '.html' ) ) && + is_file( $template ) && + is_readable( $template ) + ) { /** * Fires immediately before including the template. * diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index 095edd7dda7f5..733850aa6eb21 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '7.0-beta3-61849-src'; +$wp_version = '7.0-beta5-61991-src'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. diff --git a/src/wp-includes/widgets.php b/src/wp-includes/widgets.php index 6d2af50844313..fb872386639e0 100644 --- a/src/wp-includes/widgets.php +++ b/src/wp-includes/widgets.php @@ -711,11 +711,11 @@ function dynamic_sidebar( $index = 1 ) { $sidebars_widgets = wp_get_sidebars_widgets(); if ( empty( $wp_registered_sidebars[ $index ] ) || empty( $sidebars_widgets[ $index ] ) || ! is_array( $sidebars_widgets[ $index ] ) ) { - /** This action is documented in wp-includes/widget.php */ + /** This action is documented in wp-includes/widgets.php */ do_action( 'dynamic_sidebar_before', $index, false ); - /** This action is documented in wp-includes/widget.php */ + /** This action is documented in wp-includes/widgets.php */ do_action( 'dynamic_sidebar_after', $index, false ); - /** This filter is documented in wp-includes/widget.php */ + /** This filter is documented in wp-includes/widgets.php */ return apply_filters( 'dynamic_sidebar_has_widgets', false, $index ); } diff --git a/src/wp-settings.php b/src/wp-settings.php index 023cdccd5ecc9..dab1d8fd4c0de 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -294,6 +294,7 @@ require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; require ABSPATH . WPINC . '/ai-client.php'; +require ABSPATH . WPINC . '/class-wp-connector-registry.php'; require ABSPATH . WPINC . '/connectors.php'; require ABSPATH . WPINC . '/class-wp-icons-registry.php'; require ABSPATH . WPINC . '/widgets.php'; diff --git a/tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php b/tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php index aa3baf4b9ce0c..7513df3ff0fd5 100644 --- a/tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php +++ b/tests/phpunit/includes/wp-ai-client-mock-model-creation-trait.php @@ -18,6 +18,7 @@ use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; +use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; @@ -140,6 +141,25 @@ protected function create_test_text_to_speech_model_metadata( ); } + /** + * Creates a test model metadata instance for video generation. + * + * @param string $id Optional model ID. + * @param string $name Optional model name. + * @return ModelMetadata + */ + protected function create_test_video_model_metadata( + string $id = 'test-video-model', + string $name = 'Test Video Model' + ): ModelMetadata { + return new ModelMetadata( + $id, + $name, + array( CapabilityEnum::videoGeneration() ), + array() + ); + } + /** * Creates a mock text generation model using anonymous class. * @@ -380,6 +400,65 @@ public function convertTextToSpeechResult( array $prompt ): GenerativeAiResult { }; } + /** + * Creates a mock video generation model using anonymous class. + * + * @param GenerativeAiResult $result The result to return from generation. + * @param ModelMetadata|null $metadata Optional metadata. + * @return ModelInterface&VideoGenerationModelInterface The mock model. + */ + protected function create_mock_video_generation_model( + GenerativeAiResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->create_test_video_model_metadata(); + + $provider_metadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class( $metadata, $provider_metadata, $result ) implements ModelInterface, VideoGenerationModelInterface { + + private ModelMetadata $metadata; + private ProviderMetadata $provider_metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $provider_metadata, + GenerativeAiResult $result + ) { + $this->metadata = $metadata; + $this->provider_metadata = $provider_metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata { + return $this->provider_metadata; + } + + public function setConfig( ModelConfig $config ): void { + $this->config = $config; + } + + public function getConfig(): ModelConfig { + return $this->config; + } + + public function generateVideoResult( array $prompt ): GenerativeAiResult { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + return $this->result; + } + }; + } + /** * Creates a mock text generation model that throws an exception. * diff --git a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php index 9797017451e0d..e7637bf239119 100644 --- a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php +++ b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php @@ -155,9 +155,26 @@ trait WP_AI_Client_Mock_Provider_Trait { * Must be called from set_up_before_class() after parent::set_up_before_class(). */ private static function register_mock_connectors_provider(): void { - $registry = AiClient::defaultRegistry(); - if ( ! $registry->hasProvider( 'mock_connectors_test' ) ) { - $registry->registerProvider( Mock_Connectors_Test_Provider::class ); + $ai_registry = AiClient::defaultRegistry(); + if ( ! $ai_registry->hasProvider( 'mock_connectors_test' ) ) { + $ai_registry->registerProvider( Mock_Connectors_Test_Provider::class ); + } + + // Also register in the WP connector registry if not already present. + $connector_registry = WP_Connector_Registry::get_instance(); + if ( null !== $connector_registry && ! $connector_registry->is_registered( 'mock_connectors_test' ) ) { + $connector_registry->register( + 'mock_connectors_test', + array( + 'name' => 'Mock Connectors Test', + 'description' => '', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => null, + ), + ) + ); } } diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index a8c8ec58e7037..ea4814212d335 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -8,6 +8,7 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Files\DTO\File; +use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; @@ -1029,12 +1030,13 @@ public function test_using_model_config_with_custom_options() { $builder->using_stop_sequences( 'STOP' ); /** @var ModelConfig $merged_config */ - $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); - $custom_options = $merged_config->getCustomOptions(); + $merged_config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + $this->assertEquals( array( 'STOP' ), $merged_config->getStopSequences() ); + + $custom_options = $merged_config->getCustomOptions(); $this->assertArrayHasKey( 'stopSequences', $custom_options ); - $this->assertIsArray( $custom_options['stopSequences'] ); - $this->assertEquals( array( 'STOP' ), $custom_options['stopSequences'] ); + $this->assertEquals( array( 'CONFIG_STOP' ), $custom_options['stopSequences'] ); $this->assertArrayHasKey( 'otherOption', $custom_options ); $this->assertEquals( 'value', $custom_options['otherOption'] ); } @@ -1153,9 +1155,7 @@ public function test_using_stop_sequences() { /** @var ModelConfig $config */ $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); - $custom_options = $config->getCustomOptions(); - $this->assertArrayHasKey( 'stopSequences', $custom_options ); - $this->assertEquals( array( 'STOP', 'END', '###' ), $custom_options['stopSequences'] ); + $this->assertEquals( array( 'STOP', 'END', '###' ), $config->getStopSequences() ); } /** @@ -1558,7 +1558,7 @@ public function test_generate_result_returns_wp_error_for_unsupported_output_mod $this->assertWPError( $result ); $this->assertSame( 'prompt_builder_error', $result->get_error_code() ); - $this->assertStringContainsString( 'Output modality "video" is not yet supported', $result->get_error_message() ); + $this->assertStringContainsString( 'does not support video generation', $result->get_error_message() ); } /** @@ -2051,6 +2051,168 @@ public function test_generate_speeches() { $this->assertSame( $files[2], $speech_files[2] ); } + /** + * Tests generateVideo method. + * + * @ticket 64591 + */ + public function test_generate_video() { + $file = new File( 'https://example.com/video.mp4', 'video/mp4' ); + $message_part = new MessagePart( $file ); + $message = new Message( MessageRoleEnum::model(), array( $message_part ) ); + $candidate = new Candidate( $message, FinishReasonEnum::stop() ); + + $result = new GenerativeAiResult( + 'test-result', + array( $candidate ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_video_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_video_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate video' ); + $builder->using_model( $model ); + + $video_file = $builder->generate_video(); + $this->assertSame( $file, $video_file ); + } + + /** + * Tests generateVideos method. + * + * @ticket 64591 + */ + public function test_generate_videos() { + $files = array( + new File( 'https://example.com/video1.mp4', 'video/mp4' ), + new File( 'https://example.com/video2.mp4', 'video/mp4' ), + ); + + $candidates = array(); + foreach ( $files as $file ) { + $candidates[] = new Candidate( + new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) ), + FinishReasonEnum::stop() + ); + } + + $result = new GenerativeAiResult( + 'test-result-id', + $candidates, + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_video_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_video_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate videos' ); + $builder->using_model( $model ); + + $video_files = $builder->generate_videos( 2 ); + + $this->assertCount( 2, $video_files ); + $this->assertSame( $files[0], $video_files[0] ); + $this->assertSame( $files[1], $video_files[1] ); + } + + /** + * Tests generateVideoResult method. + * + * @ticket 64591 + */ + public function test_generate_video_result() { + $result = new GenerativeAiResult( + 'test-result', + array( + new Candidate( + new ModelMessage( array( new MessagePart( new File( 'data:video/mp4;base64,AAAAAA==', 'video/mp4' ) ) ) ), + FinishReasonEnum::stop() + ), + ), + new TokenUsage( 100, 50, 150 ), + $this->create_test_provider_metadata(), + $this->create_test_video_model_metadata() + ); + + $metadata = $this->createMock( ModelMetadata::class ); + $metadata->method( 'getId' )->willReturn( 'test-model' ); + + $model = $this->create_mock_video_generation_model( $result, $metadata ); + + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Generate video' ); + $builder->using_model( $model ); + + $actual_result = $builder->generate_video_result(); + $this->assertSame( $result, $actual_result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $modalities = $config->getOutputModalities(); + $this->assertNotNull( $modalities ); + $this->assertTrue( $modalities[0]->isVideo() ); + } + + /** + * Tests asOutputMediaOrientation method. + * + * @ticket 64591 + */ + public function test_as_output_media_orientation() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_output_media_orientation( MediaOrientationEnum::landscape() ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertTrue( $config->getOutputMediaOrientation()->isLandscape() ); + } + + /** + * Tests asOutputMediaAspectRatio method. + * + * @ticket 64591 + */ + public function test_as_output_media_aspect_ratio() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_output_media_aspect_ratio( '16:9' ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( '16:9', $config->getOutputMediaAspectRatio() ); + } + + /** + * Tests asOutputSpeechVoice method. + * + * @ticket 64591 + */ + public function test_as_output_speech_voice() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry ); + $result = $builder->as_output_speech_voice( 'alloy' ); + + $this->assertSame( $builder, $result ); + + /** @var ModelConfig $config */ + $config = $this->get_wrapped_prompt_builder_property_value( $builder, 'modelConfig' ); + + $this->assertEquals( 'alloy', $config->getOutputSpeechVoice() ); + } + /** * Tests using_abilities with ability name string. * diff --git a/tests/phpunit/tests/block-supports/block-visibility.php b/tests/phpunit/tests/block-supports/block-visibility.php index dd116472ba1f4..4f24b931744f8 100644 --- a/tests/phpunit/tests/block-supports/block-visibility.php +++ b/tests/phpunit/tests/block-supports/block-visibility.php @@ -11,18 +11,18 @@ * @covers ::wp_render_block_visibility_support */ class Tests_Block_Supports_Block_Visibility extends WP_UnitTestCase { - /** - * @var string|null - */ - private $test_block_name; - public function set_up() { + private ?string $test_block_name; + + public function set_up(): void { parent::set_up(); $this->test_block_name = null; } - public function tear_down() { - unregister_block_type( $this->test_block_name ); + public function tear_down(): void { + if ( $this->test_block_name ) { + unregister_block_type( $this->test_block_name ); + } $this->test_block_name = null; parent::tear_down(); } @@ -30,12 +30,10 @@ public function tear_down() { /** * Registers a new block for testing block visibility support. * - * @param string $block_name Name for the test block. - * @param array $supports Array defining block support configuration. - * - * @return WP_Block_Type The block type for the newly registered test block. + * @param string $block_name Name for the test block. + * @param array $supports Array defining block support configuration. */ - private function register_visibility_block_with_support( $block_name, $supports = array() ) { + private function register_visibility_block_with_support( string $block_name, array $supports = array() ): void { $this->test_block_name = $block_name; register_block_type( $this->test_block_name, @@ -51,7 +49,7 @@ private function register_visibility_block_with_support( $block_name, $supports ); $registry = WP_Block_Type_Registry::get_instance(); - return $registry->get_registered( $this->test_block_name ); + $registry->get_registered( $this->test_block_name ); } /** @@ -60,7 +58,7 @@ private function register_visibility_block_with_support( $block_name, $supports * * @ticket 64061 */ - public function test_block_visibility_support_hides_block_when_visibility_false() { + public function test_block_visibility_support_hides_block_when_visibility_false(): void { $this->register_visibility_block_with_support( 'test/visibility-block', array( 'visibility' => true ) @@ -87,13 +85,13 @@ public function test_block_visibility_support_hides_block_when_visibility_false( * * @ticket 64061 */ - public function test_block_visibility_support_shows_block_when_support_not_opted_in() { + public function test_block_visibility_support_shows_block_when_support_not_opted_in(): void { $this->register_visibility_block_with_support( 'test/visibility-block', array( 'visibility' => false ) ); - $block_content = '

      This is a test block.

      '; + $block_content = '
      Test content
      '; $block = array( 'blockName' => 'test/visibility-block', 'attrs' => array( @@ -108,10 +106,10 @@ public function test_block_visibility_support_shows_block_when_support_not_opted $this->assertSame( $block_content, $result, 'Block content should remain unchanged when blockVisibility support is not opted in.' ); } - /* + /** * @ticket 64414 */ - public function test_block_visibility_support_no_visibility_attribute() { + public function test_block_visibility_support_no_visibility_attribute(): void { $this->register_visibility_block_with_support( 'test/block-visibility-none', array( 'visibility' => true ) @@ -122,16 +120,16 @@ public function test_block_visibility_support_no_visibility_attribute() { 'attrs' => array(), ); - $block_content = '
      Test content
      '; + $block_content = '
      Test content
      '; $result = wp_render_block_visibility_support( $block_content, $block ); $this->assertSame( $block_content, $result, 'Block content should remain unchanged when no visibility attribute is present.' ); } - /* + /** * @ticket 64414 */ - public function test_block_visibility_support_generated_css_with_mobile_viewport_size() { + public function test_block_visibility_support_generated_css_with_mobile_viewport_size(): void { $this->register_visibility_block_with_support( 'test/viewport-mobile', array( 'visibility' => true ) @@ -150,7 +148,7 @@ public function test_block_visibility_support_generated_css_with_mobile_viewport ), ); - $block_content = '
      Test content
      '; + $block_content = '
      Test content
      '; $result = wp_render_block_visibility_support( $block_content, $block ); $this->assertStringContainsString( 'wp-block-hidden-mobile', $result, 'Block should have the visibility class for the mobile breakpoint.' ); @@ -164,10 +162,10 @@ public function test_block_visibility_support_generated_css_with_mobile_viewport ); } - /* + /** * @ticket 64414 */ - public function test_block_visibility_support_generated_css_with_tablet_viewport_size() { + public function test_block_visibility_support_generated_css_with_tablet_viewport_size(): void { $this->register_visibility_block_with_support( 'test/viewport-tablet', array( 'visibility' => true ) @@ -186,10 +184,15 @@ public function test_block_visibility_support_generated_css_with_tablet_viewport ), ); - $block_content = '
      Test content
      '; + $block_content = '
      Test content
      '; $result = wp_render_block_visibility_support( $block_content, $block ); - $this->assertStringContainsString( 'class="existing-class wp-block-hidden-tablet"', $result, 'Block should have the existing class and the visibility class for the tablet breakpoint in the class attribute.' ); + $this->assertEqualHTML( + '
      Test content
      ', + $result, + '', + 'Block should have the existing class and the visibility class for the tablet breakpoint in the class attribute.' + ); $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); @@ -200,10 +203,10 @@ public function test_block_visibility_support_generated_css_with_tablet_viewport ); } - /* + /** * @ticket 64414 */ - public function test_block_visibility_support_generated_css_with_desktop_breakpoint() { + public function test_block_visibility_support_generated_css_with_desktop_breakpoint(): void { $this->register_visibility_block_with_support( 'test/viewport-desktop', array( 'visibility' => true ) @@ -222,10 +225,15 @@ public function test_block_visibility_support_generated_css_with_desktop_breakpo ), ); - $block_content = '
      Test content
      '; + $block_content = '
      Test content
      '; $result = wp_render_block_visibility_support( $block_content, $block ); - $this->assertStringContainsString( 'class="wp-block-hidden-desktop"', $result, 'Block should have the visibility class for the desktop breakpoint in the class attribute.' ); + $this->assertEqualHTML( + '
      Test content
      ', + $result, + '', + 'Block should have the visibility class for the desktop breakpoint in the class attribute.' + ); $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); @@ -236,10 +244,11 @@ public function test_block_visibility_support_generated_css_with_desktop_breakpo ); } - /* + /** * @ticket 64414 + * @ticket 64823 */ - public function test_block_visibility_support_generated_css_with_two_viewport_sizes() { + public function test_block_visibility_support_generated_css_with_two_viewport_sizes(): void { $this->register_visibility_block_with_support( 'test/viewport-two', array( 'visibility' => true ) @@ -259,13 +268,14 @@ public function test_block_visibility_support_generated_css_with_two_viewport_si ), ); - $block_content = '
      Test content
      '; + $block_content = '
      Test content
      '; $result = wp_render_block_visibility_support( $block_content, $block ); - $this->assertStringContainsString( - 'class="wp-block-hidden-desktop wp-block-hidden-mobile"', + $this->assertEqualHTML( + '
      Test content
      ', $result, - 'Block should have both visibility classes in the class attribute' + '', + 'Block should have both visibility classes in the class attribute, and the IMG should have fetchpriority=auto.' ); $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); @@ -277,10 +287,11 @@ public function test_block_visibility_support_generated_css_with_two_viewport_si ); } - /* + /** * @ticket 64414 + * @ticket 64823 */ - public function test_block_visibility_support_generated_css_with_all_viewport_sizes_visible() { + public function test_block_visibility_support_generated_css_with_all_viewport_sizes_visible(): void { $this->register_visibility_block_with_support( 'test/viewport-all-visible', array( 'visibility' => true ) @@ -301,16 +312,17 @@ public function test_block_visibility_support_generated_css_with_all_viewport_si ), ); - $block_content = '
      Test content
      '; + $block_content = '
      Test content
      '; $result = wp_render_block_visibility_support( $block_content, $block ); $this->assertSame( $block_content, $result, 'Block content should remain unchanged when all breakpoints are visible.' ); } - /* + /** * @ticket 64414 + * @ticket 64823 */ - public function test_block_visibility_support_generated_css_with_all_viewport_sizes_hidden() { + public function test_block_visibility_support_generated_css_with_all_viewport_sizes_hidden(): void { $this->register_visibility_block_with_support( 'test/viewport-all-hidden', array( 'visibility' => true ) @@ -331,16 +343,21 @@ public function test_block_visibility_support_generated_css_with_all_viewport_si ), ); - $block_content = '
      Test content
      '; + $block_content = '
      Test content
      '; $result = wp_render_block_visibility_support( $block_content, $block ); - $this->assertSame( '
      Test content
      ', $result, 'Block content should have the visibility classes for all viewport sizes in the class attribute.' ); + $this->assertEqualHTML( + '
      Test content
      ', + $result, + '', + 'Block content should have the visibility classes for all viewport sizes in the class attribute, and an IMG should get fetchpriority=auto.' + ); } - /* + /** * @ticket 64414 */ - public function test_block_visibility_support_generated_css_with_empty_object() { + public function test_block_visibility_support_generated_css_with_empty_object(): void { $this->register_visibility_block_with_support( 'test/viewport-empty', array( 'visibility' => true ) @@ -355,16 +372,16 @@ public function test_block_visibility_support_generated_css_with_empty_object() ), ); - $block_content = '
      Test content
      '; + $block_content = '
      Test content
      '; $result = wp_render_block_visibility_support( $block_content, $block ); $this->assertSame( $block_content, $result, 'Block content should remain unchanged when blockVisibility is an empty array.' ); } - /* + /** * @ticket 64414 */ - public function test_block_visibility_support_generated_css_with_unknown_viewport_sizes_ignored() { + public function test_block_visibility_support_generated_css_with_unknown_viewport_sizes_ignored(): void { $this->register_visibility_block_with_support( 'test/viewport-unknown-viewport-sizes', array( 'visibility' => true ) @@ -385,7 +402,7 @@ public function test_block_visibility_support_generated_css_with_unknown_viewpor ), ); - $block_content = '
      Test content
      '; + $block_content = '
      Test content
      '; $result = wp_render_block_visibility_support( $block_content, $block ); $this->assertStringContainsString( @@ -395,10 +412,10 @@ public function test_block_visibility_support_generated_css_with_unknown_viewpor ); } - /* + /** * @ticket 64414 */ - public function test_block_visibility_support_generated_css_with_empty_content() { + public function test_block_visibility_support_generated_css_with_empty_content(): void { $this->register_visibility_block_with_support( 'test/viewport-empty-content', array( 'visibility' => true ) diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php new file mode 100644 index 0000000000000..161739b7a8ab8 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -0,0 +1,384 @@ + + * @phpstan-var Connector + */ + private static array $default_args; + + /** + * Set up each test method. + */ + public function set_up(): void { + parent::set_up(); + + $this->registry = new WP_Connector_Registry(); + + self::$default_args = array( + 'name' => 'Test Provider', + 'description' => 'A test AI provider.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + } + + /** + * @ticket 64791 + */ + public function test_register_returns_connector_data() { + $result = $this->registry->register( 'test_provider', self::$default_args ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + $this->assertSame( 'A test AI provider.', $result['description'] ); + $this->assertSame( 'ai_provider', $result['type'] ); + $this->assertSame( 'api_key', $result['authentication']['method'] ); + $this->assertSame( 'https://example.com/keys', $result['authentication']['credentials_url'] ); + $this->assertSame( 'connectors_ai_test_provider_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_generates_setting_name_for_api_key() { + $result = $this->registry->register( 'my_ai', self::$default_args ); + + $this->assertSame( 'connectors_ai_my_ai_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_no_setting_name_for_none_auth() { + $args = array( + 'name' => 'No Auth Provider', + 'type' => 'ai_provider', + 'authentication' => array( 'method' => 'none' ), + ); + $result = $this->registry->register( 'no_auth', $args ); + + $this->assertIsArray( $result ); + $this->assertArrayNotHasKey( 'setting_name', $result['authentication'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_defaults_description_to_empty_string() { + $args = array( + 'name' => 'Minimal', + 'type' => 'ai_provider', + 'authentication' => array( 'method' => 'none' ), + ); + + $result = $this->registry->register( 'minimal', $args ); + + $this->assertSame( '', $result['description'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_includes_logo_url() { + $args = self::$default_args; + $args['logo_url'] = 'https://example.com/logo.png'; + + $result = $this->registry->register( 'with_logo', $args ); + + $this->assertArrayHasKey( 'logo_url', $result ); + $this->assertSame( 'https://example.com/logo.png', $result['logo_url'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_omits_logo_url_when_not_provided() { + $result = $this->registry->register( 'no_logo', self::$default_args ); + + $this->assertArrayNotHasKey( 'logo_url', $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_omits_logo_url_when_empty() { + $args = self::$default_args; + $args['logo_url'] = ''; + + $result = $this->registry->register( 'empty_logo', $args ); + + $this->assertArrayNotHasKey( 'logo_url', $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_includes_plugin_data() { + $args = self::$default_args; + $args['plugin'] = array( 'slug' => 'my-plugin' ); + + $result = $this->registry->register( 'with_plugin', $args ); + + $this->assertArrayHasKey( 'plugin', $result ); + $this->assertSame( array( 'slug' => 'my-plugin' ), $result['plugin'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_omits_plugin_when_not_provided() { + $result = $this->registry->register( 'no_plugin', self::$default_args ); + + $this->assertArrayNotHasKey( 'plugin', $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_id_with_uppercase() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( 'InvalidId', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_id_with_dashes() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( 'my-provider', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_empty_id() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( '', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_duplicate_id() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $this->registry->register( 'duplicate', self::$default_args ); + $result = $this->registry->register( 'duplicate', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['name'] ); + + $result = $this->registry->register( 'no_name', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_empty_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['name'] = ''; + + $result = $this->registry->register( 'empty_name', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_type() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['type'] ); + + $result = $this->registry->register( 'no_type', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_authentication() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['authentication'] ); + + $result = $this->registry->register( 'no_auth', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_auth_method() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['authentication']['method'] = 'oauth'; + + $result = $this->registry->register( 'bad_auth', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_is_registered_returns_true_for_registered() { + $this->registry->register( 'exists', self::$default_args ); + + $this->assertTrue( $this->registry->is_registered( 'exists' ) ); + } + + /** + * @ticket 64791 + */ + public function test_is_registered_returns_false_for_unregistered() { + $this->assertFalse( $this->registry->is_registered( 'does_not_exist' ) ); + } + + /** + * @ticket 64791 + */ + public function test_get_registered_returns_connector_data() { + $this->registry->register( 'my_connector', self::$default_args ); + + $result = $this->registry->get_registered( 'my_connector' ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + } + + /** + * @ticket 64791 + */ + public function test_get_registered_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' ); + + $result = $this->registry->get_registered( 'nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_all_registered_returns_all_connectors() { + $this->registry->register( 'first', self::$default_args ); + + $args = self::$default_args; + $args['name'] = 'Second Provider'; + $this->registry->register( 'second', $args ); + + $all = $this->registry->get_all_registered(); + + $this->assertCount( 2, $all ); + $this->assertArrayHasKey( 'first', $all ); + $this->assertArrayHasKey( 'second', $all ); + } + + /** + * @ticket 64791 + */ + public function test_get_all_registered_returns_empty_when_none() { + $this->assertSame( array(), $this->registry->get_all_registered() ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_removes_connector() { + $this->registry->register( 'to_remove', self::$default_args ); + + $result = $this->registry->unregister( 'to_remove' ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + $this->assertFalse( $this->registry->is_registered( 'to_remove' ) ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' ); + + $result = $this->registry->unregister( 'nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_instance_returns_registry() { + $instance = WP_Connector_Registry::get_instance(); + + $this->assertInstanceOf( WP_Connector_Registry::class, $instance ); + } + + /** + * @ticket 64791 + */ + public function test_set_instance_rejects_after_init() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::set_instance' ); + + WP_Connector_Registry::set_instance( new WP_Connector_Registry() ); + } + + /** + * @ticket 64791 + */ + public function test_get_instance_returns_same_instance() { + $instance1 = WP_Connector_Registry::get_instance(); + $instance2 = WP_Connector_Registry::get_instance(); + + $this->assertSame( $instance1, $instance2 ); + } +} diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index f2d0aa68ee0e1..8cb7a5c5d2d90 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -3,19 +3,19 @@ require_once dirname( __DIR__, 2 ) . '/includes/wp-ai-client-mock-provider-trait.php'; /** - * Tests for _wp_connectors_get_connector_settings(). + * Tests for wp_get_connectors(). * * @group connectors - * @covers ::_wp_connectors_get_connector_settings + * @covers ::wp_get_connectors */ -class Tests_Connectors_WpConnectorsGetConnectorSettings extends WP_UnitTestCase { +class Tests_Connectors_WpGetConnectors extends WP_UnitTestCase { use WP_AI_Client_Mock_Provider_Trait; /** * Registers the mock provider once before any tests in this class run. */ - public static function set_up_before_class() { + public static function set_up_before_class(): void { parent::set_up_before_class(); self::register_mock_connectors_provider(); } @@ -23,7 +23,7 @@ public static function set_up_before_class() { /** * Unregisters the mock provider setting added by `init`. */ - public static function tear_down_after_class() { + public static function tear_down_after_class(): void { self::unregister_mock_connector_setting(); parent::tear_down_after_class(); } @@ -31,8 +31,8 @@ public static function tear_down_after_class() { /** * @ticket 64730 */ - public function test_returns_expected_connector_keys() { - $connectors = _wp_connectors_get_connector_settings(); + public function test_returns_expected_connector_keys(): void { + $connectors = wp_get_connectors(); $this->assertArrayHasKey( 'google', $connectors ); $this->assertArrayHasKey( 'openai', $connectors ); @@ -44,8 +44,8 @@ public function test_returns_expected_connector_keys() { /** * @ticket 64730 */ - public function test_each_connector_has_required_fields() { - $connectors = _wp_connectors_get_connector_settings(); + public function test_each_connector_has_required_fields(): void { + $connectors = wp_get_connectors(); $this->assertNotEmpty( $connectors, 'Connector settings should not be empty.' ); @@ -67,8 +67,8 @@ public function test_each_connector_has_required_fields() { /** * @ticket 64730 */ - public function test_api_key_connectors_have_setting_name_and_credentials_url() { - $connectors = _wp_connectors_get_connector_settings(); + public function test_api_key_connectors_have_setting_name_and_credentials_url(): void { + $connectors = wp_get_connectors(); $api_key_count = 0; foreach ( $connectors as $connector_id => $connector_data ) { @@ -81,10 +81,9 @@ public function test_api_key_connectors_have_setting_name_and_credentials_url() $this->assertArrayHasKey( 'setting_name', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'setting_name'." ); $this->assertSame( "connectors_ai_{$connector_id}_api_key", - $connector_data['authentication']['setting_name'], + $connector_data['authentication']['setting_name'] ?? null, "Connector '{$connector_id}' setting_name does not match expected format." ); - $this->assertArrayHasKey( 'credentials_url', $connector_data['authentication'], "Connector '{$connector_id}' authentication is missing 'credentials_url'." ); } $this->assertGreaterThan( 0, $api_key_count, 'At least one connector should use api_key authentication.' ); @@ -93,8 +92,8 @@ public function test_api_key_connectors_have_setting_name_and_credentials_url() /** * @ticket 64730 */ - public function test_featured_provider_names_match_expected() { - $connectors = _wp_connectors_get_connector_settings(); + public function test_featured_provider_names_match_expected(): void { + $connectors = wp_get_connectors(); $this->assertSame( 'Google', $connectors['google']['name'] ); $this->assertSame( 'OpenAI', $connectors['openai']['name'] ); @@ -104,15 +103,15 @@ public function test_featured_provider_names_match_expected() { /** * @ticket 64730 */ - public function test_includes_registered_provider_from_registry() { - $connectors = _wp_connectors_get_connector_settings(); + public function test_includes_registered_provider_from_registry(): void { + $connectors = wp_get_connectors(); $mock = $connectors['mock_connectors_test']; $this->assertSame( 'Mock Connectors Test', $mock['name'] ); $this->assertSame( '', $mock['description'] ); $this->assertSame( 'ai_provider', $mock['type'] ); $this->assertSame( 'api_key', $mock['authentication']['method'] ); - $this->assertNull( $mock['authentication']['credentials_url'] ); - $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] ); + $this->assertNull( $mock['authentication']['credentials_url'] ?? null ); + $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] ?? null ); } } diff --git a/tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php b/tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php new file mode 100644 index 0000000000000..71b1628af3311 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php @@ -0,0 +1,107 @@ +created_files as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + } + } + foreach ( array_reverse( $this->created_dirs ) as $dir ) { + if ( is_dir( $dir ) ) { + rmdir( $dir ); + } + } + parent::tear_down(); + } + + /** + * Creates a temporary file and tracks it for cleanup. + * + * @param string $path File path. + */ + private function create_file( string $path ): void { + $dir = dirname( $path ); + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + $this->created_dirs[] = $dir; + } + file_put_contents( $path, '' ); + $this->created_files[] = $path; + } + + /** + * @ticket 64791 + */ + public function test_returns_null_when_path_is_empty() { + $this->assertNull( _wp_connectors_resolve_ai_provider_logo_url( '' ) ); + } + + /** + * @ticket 64791 + */ + public function test_resolves_plugin_dir_path_to_url() { + $logo_path = WP_PLUGIN_DIR . '/my-plugin/logo.svg'; + $this->create_file( $logo_path ); + + $result = _wp_connectors_resolve_ai_provider_logo_url( $logo_path ); + + $this->assertSame( site_url( '/wp-content/plugins/my-plugin/logo.svg' ), $result ); + } + + /** + * @ticket 64791 + */ + public function test_resolves_mu_plugin_dir_path_to_url() { + $logo_path = WPMU_PLUGIN_DIR . '/my-mu-plugin/logo.svg'; + $this->create_file( $logo_path ); + + $result = _wp_connectors_resolve_ai_provider_logo_url( $logo_path ); + + $this->assertSame( site_url( '/wp-content/mu-plugins/my-mu-plugin/logo.svg' ), $result ); + } + + /** + * @ticket 64791 + */ + public function test_returns_null_when_file_does_not_exist() { + $this->assertNull( + _wp_connectors_resolve_ai_provider_logo_url( WP_PLUGIN_DIR . '/nonexistent/logo.svg' ) + ); + } + + /** + * @ticket 64791 + * @expectedIncorrectUsage _wp_connectors_resolve_ai_provider_logo_url + */ + public function test_returns_null_and_triggers_doing_it_wrong_for_path_outside_plugin_dirs() { + $tmp_file = tempnam( sys_get_temp_dir(), 'logo_' ); + file_put_contents( $tmp_file, '' ); + $this->created_files[] = $tmp_file; + + $this->assertNull( _wp_connectors_resolve_ai_provider_logo_url( $tmp_file ) ); + } +} diff --git a/tests/phpunit/tests/connectors/wpRegisterConnector.php b/tests/phpunit/tests/connectors/wpRegisterConnector.php new file mode 100644 index 0000000000000..ad55c012d97c7 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpRegisterConnector.php @@ -0,0 +1,63 @@ +assertTrue( wp_is_connector_registered( 'openai' ) ); + $this->assertTrue( wp_is_connector_registered( 'google' ) ); + $this->assertTrue( wp_is_connector_registered( 'anthropic' ) ); + } + + /** + * @ticket 64791 + */ + public function test_is_connector_registered_returns_false_for_unregistered() { + $this->assertFalse( wp_is_connector_registered( 'nonexistent_provider' ) ); + } + + /** + * @ticket 64791 + */ + public function test_get_connector_returns_data_for_default() { + $connector = wp_get_connector( 'openai' ); + + $this->assertIsArray( $connector ); + $this->assertSame( 'OpenAI', $connector['name'] ); + $this->assertSame( 'ai_provider', $connector['type'] ); + $this->assertSame( 'api_key', $connector['authentication']['method'] ); + $this->assertSame( 'connectors_ai_openai_api_key', $connector['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_get_connector_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' ); + + $result = wp_get_connector( 'nonexistent_provider' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_connectors_returns_all_defaults() { + $connectors = wp_get_connectors(); + + $this->assertArrayHasKey( 'openai', $connectors ); + $this->assertArrayHasKey( 'google', $connectors ); + $this->assertArrayHasKey( 'anthropic', $connectors ); + } +} diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index 6050983cc5f5e..5f1c30fe4cf47 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -631,7 +631,7 @@ public function data_provider_to_test_various_strategy_dependency_chains() { scriptEventLog.push( "blocking-not-async-without-dependency: before inline" ) //# sourceURL=blocking-not-async-without-dependency-js-before - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + HTML , ), @@ -1031,7 +1031,7 @@ public function data_provider_to_test_various_strategy_dependency_chains() { $this->add_test_inline_script( $handle, 'after' ); }, 'expected_markup' => << + - - + + + - + - - + + + HTML , ), @@ -4192,6 +4192,71 @@ public function test_varying_versions_added_to_handle_args_registered_then_enque $this->assertEqualHTML( $expected, $markup, '', 'Expected equal snapshot for wp_print_scripts() with version ' . var_export( $version, true ) . ":\n$markup" ); } + /** + * Tests that duplicate query vars and fragments are preserved in scripts. + * + * @ticket 64372 + * + * @dataProvider data_duplicate_query_vars_and_fragments_preserved_in_scripts + * + * @param string $src The script's source URL. + * @param string|bool|null $ver The script's version. + * @param string $expected_url The expected URL. + * @param string $handle Optional. The script's registered handle. Default 'test-script'. + */ + public function test_duplicate_query_vars_and_fragments_preserved_in_scripts( string $src, $ver, string $expected_url, string $handle = 'test-script' ): void { + wp_enqueue_script( $handle, $src, array(), $ver ); + $output = get_echo( 'wp_print_scripts' ); + $processor = new WP_HTML_Tag_Processor( $output ); + + $this->assertTrue( $processor->next_tag( 'script' ) ); + $this->assertSame( $expected_url, $processor->get_attribute( 'src' ) ); + } + + /** + * Data provider for test_duplicate_query_vars_and_fragments_preserved_in_scripts. + * + * @return array Data provider. + */ + public function data_duplicate_query_vars_and_fragments_preserved_in_scripts(): array { + $ver = get_bloginfo( 'version' ); + + return array( + 'duplicate query vars' => array( + 'src' => 'https://example.com/script.js?arg=1&arg=2', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/script.js?arg=1&arg=2&ver=1.0', + ), + 'duplicate query vars, null version' => array( + 'src' => 'https://example.com/script.js?arg=1&arg=2', + 'ver' => null, + 'expected_url' => 'https://example.com/script.js?arg=1&arg=2', + ), + 'duplicate query vars, false version' => array( + 'src' => 'https://example.com/script.js?arg=1&arg=2', + 'ver' => false, + 'expected_url' => "https://example.com/script.js?arg=1&arg=2&ver=$ver", + ), + 'duplicate query vars in handle' => array( + 'src' => 'https://example.com/test-script.js', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/test-script.js?ver=1.0&a=1&a=2', + 'handle' => 'test-script?a=1&a=2', + ), + 'duplicate query vars and fragments' => array( + 'src' => 'https://example.com/script.js?arg=1&arg=2#anchor', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/script.js?arg=1&arg=2&ver=1.0#anchor', + ), + 'zero query var in handle' => array( + 'src' => 'https://example.com/test-script.js', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/test-script.js?ver=1.0&0', + 'handle' => 'test-script?0', + ), + ); + } + /** * Data provider for: * - test_varying_versions_added_to_handle_args_enqueued_scripts diff --git a/tests/phpunit/tests/dependencies/styles.php b/tests/phpunit/tests/dependencies/styles.php index bbaf9432d8df0..de6fefb94b5bb 100644 --- a/tests/phpunit/tests/dependencies/styles.php +++ b/tests/phpunit/tests/dependencies/styles.php @@ -961,6 +961,71 @@ public function test_varying_versions_added_to_handle_args_registered_then_enque $this->assertEqualHTML( $expected, $markup, '', 'Expected equal snapshot for wp_print_styles() with version ' . var_export( $version, true ) . ":\n$markup" ); } + /** + * Tests that duplicate query vars and fragments are preserved in styles. + * + * @ticket 64372 + * + * @dataProvider data_duplicate_query_vars_and_fragments_preserved_in_styles + * + * @param string $src The stylesheet's source URL. + * @param string|bool|null $ver The style's version. + * @param string $expected_url The expected URL. + * @param string $handle Optional. The style's registered handle. Default 'test-style'. + */ + public function test_duplicate_query_vars_and_fragments_preserved_in_styles( string $src, $ver, string $expected_url, string $handle = 'test-style' ): void { + wp_enqueue_style( $handle, $src, array(), $ver ); + $output = get_echo( 'wp_print_styles' ); + $processor = new WP_HTML_Tag_Processor( $output ); + + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertSame( $expected_url, $processor->get_attribute( 'href' ) ); + } + + /** + * Data provider for test_duplicate_query_vars_and_fragments_preserved_in_styles. + * + * @return array Data provider. + */ + public function data_duplicate_query_vars_and_fragments_preserved_in_styles(): array { + $ver = get_bloginfo( 'version' ); + + return array( + 'duplicate query vars' => array( + 'src' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap', + 'ver' => '1.0', + 'expected_url' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap&ver=1.0', + ), + 'duplicate query vars, null version' => array( + 'src' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap', + 'ver' => null, + 'expected_url' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap', + ), + 'duplicate query vars, false version' => array( + 'src' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap', + 'ver' => false, + 'expected_url' => "https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap&ver=$ver", + ), + 'duplicate query vars in handle' => array( + 'src' => 'https://example.com/test-style.css', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/test-style.css?ver=1.0&a=1&a=2', + 'handle' => 'test-style?a=1&a=2', + ), + 'duplicate query vars and fragments' => array( + 'src' => 'https://example.com/style.css?arg=1&arg=2#anchor', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/style.css?arg=1&arg=2&ver=1.0#anchor', + ), + 'zero query var in handle' => array( + 'src' => 'https://example.com/test-style.css', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/test-style.css?ver=1.0&0', + 'handle' => 'test-style?0', + ), + ); + } + /** * Data provider for: * - test_varying_versions_added_to_handle_args_enqueued_styles diff --git a/tests/phpunit/tests/functions/wpGetBranchVersion.php b/tests/phpunit/tests/functions/wpGetBranchVersion.php new file mode 100644 index 0000000000000..37ed220478436 --- /dev/null +++ b/tests/phpunit/tests/functions/wpGetBranchVersion.php @@ -0,0 +1,98 @@ +assertSame( $expected, wp_get_branch_version( $version ) ); + } + + /** + * Data provider for major version extraction. + * + * @return array[] + */ + public function data_major_version() { + return array( + 'major ending with 0 and no minor' => array( '7.0', '7.0' ), + 'minor number zero' => array( '7.0.0', '7.0' ), + 'minor with a major that ends in zero' => array( '7.0.1', '7.0' ), + 'double digit minor with trailing zero' => array( '7.0.10', '7.0' ), + 'double digit first part of major having zero' => array( '10.0.0', '10.0' ), + 'triple digit major' => array( '100.1.0', '100.1' ), + 'typical release' => array( '6.9', '6.9' ), + 'typical minor release' => array( '6.9.1', '6.9' ), + 'alpha suffix' => array( '7.0-alpha-61215', '7.0' ), + 'beta suffix' => array( '7.0-beta3-61849', '7.0' ), + 'RC suffix' => array( '7.0-RC1', '7.0' ), + 'src suffix' => array( '7.0-alpha-61215-src', '7.0' ), + 'single component' => array( '7', '7.0' ), + ); + } + + /** + * Tests that wp_get_wp_version( 'major' ) returns the expected major version. + * + * @ticket 64830 + */ + public function test_wp_get_wp_version_major() { + $expected = wp_get_branch_version( wp_get_wp_version() ); + $this->assertSame( $expected, wp_get_wp_version( 'major' ) ); + } + + /** + * Tests that wp_get_wp_version( 'minor' ) returns the expected minor version. + * + * @ticket 64830 + */ + public function test_wp_get_wp_version_minor() { + $full = wp_get_wp_version(); + $parts = preg_split( '/[.-]/', $full, 4 ); + $expected = $parts[0] . '.' . ( $parts[1] ?? '0' ) . '.' . ( $parts[2] ?? '0' ); + $this->assertSame( $expected, wp_get_wp_version( 'minor' ) ); + } + + /** + * Tests that wp_get_wp_version() with no argument still returns the full version. + * + * @ticket 64830 + */ + public function test_wp_get_wp_version_full_default() { + $this->assertSame( $GLOBALS['wp_version'], wp_get_wp_version() ); + } + + /** + * Tests that wp_get_wp_version( 'full' ) returns the full version. + * + * @ticket 64830 + */ + public function test_wp_get_wp_version_full_explicit() { + $this->assertSame( $GLOBALS['wp_version'], wp_get_wp_version( 'full' ) ); + } + + /** + * Tests that wp_get_branch_version() with no argument returns the current major version. + * + * @ticket 64830 + */ + public function test_wp_get_branch_version_defaults_to_current() { + $this->assertSame( wp_get_wp_version( 'major' ), wp_get_branch_version() ); + } +} diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index 0822de252d1b7..060a7295f6bb4 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -3916,7 +3916,10 @@ public function test_wp_get_loading_attr_default( $context ) { $this->assertFalse( wp_get_loading_attr_default( 'template_part_' . WP_TEMPLATE_PART_AREA_HEADER ), 'Images in the footer block template part should not be lazy-loaded.' ); } - public function data_wp_get_loading_attr_default() { + /** + * @return array + */ + public function data_wp_get_loading_attr_default(): array { return array( array( 'the_content' ), array( 'the_post_thumbnail' ), @@ -4481,7 +4484,7 @@ public function data_special_contexts_for_the_content_wp_get_loading_attr_defaul } /** - * Tests that wp_get_loading_attr_default() returns the expected loading attribute value. + * Tests that wp_get_loading_optimization_attributes() returns the expected loading attribute value. * * @ticket 53675 * @ticket 56930 @@ -4494,7 +4497,7 @@ public function data_special_contexts_for_the_content_wp_get_loading_attr_defaul * * @param string $context */ - public function test_wp_get_loading_optimization_attributes( $context ) { + public function test_wp_get_loading_optimization_attributes( string $context ): void { $attr = $this->get_width_height_for_high_priority(); // Return 'lazy' by default. @@ -4513,6 +4516,8 @@ public function test_wp_get_loading_optimization_attributes( $context ) { wp_get_loading_optimization_attributes( 'img', $attr, 'wp_get_attachment_image' ) ); + $this->assert_fetchpriority_low_loading_attrs( $attr, 'wp_get_attachment_image' ); + // Return 'lazy' if not in the loop or the main query. $this->assertSameSetsWithIndex( array( @@ -4527,6 +4532,8 @@ public function test_wp_get_loading_optimization_attributes( $context ) { while ( have_posts() ) { the_post(); + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + // Return 'lazy' if in the loop but not in the main query. $this->assertSameSetsWithIndex( array( @@ -4539,6 +4546,8 @@ public function test_wp_get_loading_optimization_attributes( $context ) { // Set as main query. $this->set_main_query( $query ); + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + // First three element are not lazy loaded. However, first image is loaded with fetchpriority high. $this->assertSameSetsWithIndex( array( @@ -4546,8 +4555,11 @@ public function test_wp_get_loading_optimization_attributes( $context ) { 'fetchpriority' => 'high', ), wp_get_loading_optimization_attributes( 'img', $attr, $context ), - "Expected first image to not be lazy-loaded. First large image get's high fetchpriority." + 'Expected first image to not be lazy-loaded. First large image gets high fetchpriority.' ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + $this->assertSameSetsWithIndex( array( 'decoding' => 'async', @@ -4555,6 +4567,9 @@ public function test_wp_get_loading_optimization_attributes( $context ) { wp_get_loading_optimization_attributes( 'img', $attr, $context ), 'Expected second image to not be lazy-loaded.' ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + $this->assertSameSetsWithIndex( array( 'decoding' => 'async', @@ -4563,6 +4578,8 @@ public function test_wp_get_loading_optimization_attributes( $context ) { 'Expected third image to not be lazy-loaded.' ); + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + // Return 'lazy' if in the loop and in the main query for any subsequent elements. $this->assertSameSetsWithIndex( array( @@ -4572,6 +4589,8 @@ public function test_wp_get_loading_optimization_attributes( $context ) { wp_get_loading_optimization_attributes( 'img', $attr, $context ) ); + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + // Yes, for all subsequent elements. $this->assertSameSetsWithIndex( array( @@ -4580,6 +4599,161 @@ public function test_wp_get_loading_optimization_attributes( $context ) { ), wp_get_loading_optimization_attributes( 'img', $attr, $context ) ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + 'fetchpriority' => 'auto', + 'loading' => 'lazy', + ), + wp_get_loading_optimization_attributes( + 'img', + array_merge( $attr, array( 'fetchpriority' => 'auto' ) ), + $context + ), + 'Expected a fetchpriority=auto IMG appearing after the media count threshold to still be lazy-loaded.' + ); + } + } + + /** + * Tests that wp_get_loading_optimization_attributes() returns the expected loading attribute value. + * + * This test is the same as {@see self::test_wp_get_loading_optimization_attributes()} except that the IMG which + * previously got `fetchpriority=high` now initially has `fetchpriority=auto`. This causes the initial lazy-loaded + * image to be bumped down one. + * + * @ticket 64823 + * + * @covers ::wp_get_loading_optimization_attributes + * + * @dataProvider data_wp_get_loading_attr_default + * + * @param string $context + */ + public function test_wp_get_loading_optimization_attributes_with_fetchpriority_auto_for_lcp_candidate( string $context ): void { + $attr = $this->get_width_height_for_high_priority(); + + // Return 'lazy' by default. + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + 'loading' => 'lazy', + ), + wp_get_loading_optimization_attributes( 'img', $attr, 'test' ) + ); + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + 'loading' => 'lazy', + ), + wp_get_loading_optimization_attributes( 'img', $attr, 'wp_get_attachment_image' ) + ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, 'wp_get_attachment_image' ); + + // Return 'lazy' if not in the loop or the main query. + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + 'loading' => 'lazy', + ), + wp_get_loading_optimization_attributes( 'img', $attr, $context ) + ); + + $query = $this->get_new_wp_query_for_published_post(); + + while ( have_posts() ) { + the_post(); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + + // Return 'lazy' if in the loop but not in the main query. + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + 'loading' => 'lazy', + ), + wp_get_loading_optimization_attributes( 'img', $attr, $context ) + ); + + // Set as main query. + $this->set_main_query( $query ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + + // First three element are not lazy loaded. However, first image initially has `fetchpriority=auto` which marks it as a possible LCP element. + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + 'fetchpriority' => 'auto', + ), + wp_get_loading_optimization_attributes( + 'img', + array_merge( $attr, array( 'fetchpriority' => 'auto' ) ), + $context + ), + 'Expected first image to not be lazy-loaded.' + ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + ), + wp_get_loading_optimization_attributes( 'img', $attr, $context ), + 'Expected second image to not be lazy-loaded.' + ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + ), + wp_get_loading_optimization_attributes( 'img', $attr, $context ), + 'Expected third image to not be lazy-loaded.' + ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + + // This is the 4th subsequent image, and it still is not lazy-loaded because the first had fetchpriority=auto and so it may have been hidden with block visibility. + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + ), + wp_get_loading_optimization_attributes( 'img', $attr, $context ) + ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + + // Yes, for all subsequent elements. + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + 'loading' => 'lazy', + ), + wp_get_loading_optimization_attributes( 'img', $attr, $context ) + ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + + $this->assertSameSetsWithIndex( + array( + 'decoding' => 'async', + 'fetchpriority' => 'auto', + 'loading' => 'lazy', + ), + wp_get_loading_optimization_attributes( + 'img', + array_merge( $attr, array( 'fetchpriority' => 'auto' ) ), + $context + ), + 'Expected a fetchpriority=auto IMG appearing after the media count threshold to still be lazy-loaded.' + ); } } @@ -4606,12 +4780,17 @@ public function test_wp_get_loading_optimization_attributes_with_arbitrary_conte 'The "loading" attribute should be "lazy" when not in the loop or the main query.' ); + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + $query = $this->get_new_wp_query_for_published_post(); // Set as main query. $this->set_main_query( $query ); while ( have_posts() ) { + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + the_post(); $this->assertSameSetsWithIndex( @@ -4656,9 +4835,13 @@ public function test_wp_get_loading_optimization_attributes_with_arbitrary_conte 'The "loading" attribute should be "lazy" before the main query loop.' ); + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + while ( have_posts() ) { the_post(); + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + $this->assertSameSetsWithIndex( array( 'decoding' => 'async', @@ -4740,6 +4923,8 @@ public function test_wp_get_loading_optimization_attributes_header_contexts( $co wp_get_loading_optimization_attributes( 'img', $attr, $context ), 'Images in the header context should get lazy-loaded after the wp_loading_optimization_force_header_contexts filter.' ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); } /** @@ -4764,12 +4949,14 @@ public function test_wp_loading_optimization_force_header_contexts_filter() { add_filter( 'wp_loading_optimization_force_header_contexts', - function ( $context ) { + function ( $contexts ) { $contexts['something_completely_arbitrary'] = true; return $contexts; } ); + $this->assert_fetchpriority_low_loading_attrs( $attr, 'something_completely_arbitrary' ); + $this->assertSameSetsWithIndex( array( 'decoding' => 'async', @@ -4809,6 +4996,8 @@ public function test_wp_get_loading_optimization_attributes_before_loop_if_not_m ), wp_get_loading_optimization_attributes( 'img', $attr, $context ) ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); } /** @@ -4840,6 +5029,8 @@ public function test_wp_get_loading_optimization_attributes_before_loop_in_main_ ), wp_get_loading_optimization_attributes( 'img', $attr, $context ) ); + + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); } /** @@ -4863,6 +5054,8 @@ public function test_wp_get_loading_optimization_attributes_before_loop_if_main_ $attr = $this->get_width_height_for_high_priority(); + $this->assert_fetchpriority_low_loading_attrs( $attr, $context ); + // First image is loaded with high fetchpriority. $this->assertSameSetsWithIndex( array( @@ -6049,52 +6242,83 @@ function ( $atts, $content = null ) { * * @dataProvider data_wp_maybe_add_fetchpriority_high_attr */ - public function test_wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr, $expected_fetchpriority ) { + public function test_wp_maybe_add_fetchpriority_high_attr( array $loading_attrs, string $tag_name, array $attr, ?string $expected_fetchpriority, bool $expected_high_priority_element_flag ): void { $loading_attrs = wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr ); - if ( $expected_fetchpriority ) { + if ( null !== $expected_fetchpriority ) { $this->assertArrayHasKey( 'fetchpriority', $loading_attrs, 'fetchpriority attribute should be present' ); $this->assertSame( $expected_fetchpriority, $loading_attrs['fetchpriority'], 'fetchpriority attribute has incorrect value' ); } else { $this->assertArrayNotHasKey( 'fetchpriority', $loading_attrs, 'fetchpriority attribute should not be present' ); } + $this->assertSame( $expected_high_priority_element_flag, wp_high_priority_element_flag() ); } /** * Data provider. * - * @return array[] + * @return array, + * 1: string, + * 2: array, + * 3: string|null, + * 4: bool, + * }> */ - public function data_wp_maybe_add_fetchpriority_high_attr() { + public function data_wp_maybe_add_fetchpriority_high_attr(): array { return array( - 'small image' => array( + 'small image' => array( array(), 'img', $this->get_insufficient_width_height_for_high_priority(), - false, + null, + true, + ), + 'small image with fetchpriority=auto' => array( + array(), + 'img', + array_merge( + $this->get_insufficient_width_height_for_high_priority(), + array( 'fetchpriority' => 'auto' ) + ), + null, + true, ), - 'large image' => array( + 'large image' => array( array(), 'img', $this->get_width_height_for_high_priority(), 'high', + false, + ), + 'large image with fetchpriority=auto' => array( + array(), + 'img', + array_merge( + $this->get_width_height_for_high_priority(), + array( 'fetchpriority' => 'auto' ) + ), + null, + false, ), - 'image with loading=lazy' => array( + 'image with loading=lazy' => array( array( 'loading' => 'lazy', 'decoding' => 'async', ), 'img', $this->get_width_height_for_high_priority(), - false, + null, + true, ), - 'image with loading=eager' => array( + 'image with loading=eager' => array( array( 'loading' => 'eager' ), 'img', $this->get_width_height_for_high_priority(), 'high', + false, ), - 'image with fetchpriority=high' => array( + 'image with fetchpriority=high' => array( array(), 'img', array_merge( @@ -6102,21 +6326,24 @@ public function data_wp_maybe_add_fetchpriority_high_attr() { array( 'fetchpriority' => 'high' ) ), 'high', + false, ), - 'image with fetchpriority=low' => array( + 'image with fetchpriority=low' => array( array(), 'img', array_merge( $this->get_insufficient_width_height_for_high_priority(), array( 'fetchpriority' => 'low' ) ), - false, + null, + true, ), - 'non-image element' => array( + 'non-image element' => array( array(), 'video', $this->get_width_height_for_high_priority(), - false, + null, + true, ), ); } @@ -6309,6 +6536,27 @@ static function ( $loading_attrs ) { ); } + /** + * Asserts that loading attributes for IMG with fetchpriority=low. + * + * It must not get lazy-loaded or increase the counter since they may be in the Navigation Overlay. + * + * @param array $attr + * @param string $context + */ + protected function assert_fetchpriority_low_loading_attrs( array $attr, string $context ): void { + $this->assertSameSetsWithIndex( + array( + 'fetchpriority' => 'low', + 'decoding' => 'async', + ), + wp_get_loading_optimization_attributes( + 'img', + array_merge( $attr, array( 'fetchpriority' => 'low' ) ), + $context + ) + ); + } /** * Test WebP lossless quality is handled correctly. @@ -7007,9 +7255,9 @@ public function set_main_query( $query ) { /** * Returns an array with dimension attribute values eligible for a high priority image. * - * @return array Associative array with 'width' and 'height' keys. + * @return array{ width: int, height: int } Associative array with 'width' and 'height' keys. */ - private function get_width_height_for_high_priority() { + private function get_width_height_for_high_priority(): array { /* * The product of width * height must be >50000 to qualify for high priority image. * 300 * 200 = 60000 @@ -7023,9 +7271,9 @@ private function get_width_height_for_high_priority() { /** * Returns an array with dimension attribute values ineligible for a high priority image. * - * @return array Associative array with 'width' and 'height' keys. + * @return array{ width: int, height: int } Associative array with 'width' and 'height' keys. */ - private function get_insufficient_width_height_for_high_priority() { + private function get_insufficient_width_height_for_high_priority(): array { /* * The product of width * height must be >50000 to qualify for high priority image. * 200 * 100 = 20000 diff --git a/tests/phpunit/tests/media/wpCrossOriginIsolation.php b/tests/phpunit/tests/media/wpCrossOriginIsolation.php index 31f2e85975ee0..4fe5723bdc426 100644 --- a/tests/phpunit/tests/media/wpCrossOriginIsolation.php +++ b/tests/phpunit/tests/media/wpCrossOriginIsolation.php @@ -6,19 +6,36 @@ * @group media * @covers ::wp_set_up_cross_origin_isolation * @covers ::wp_start_cross_origin_isolation_output_buffer + * @covers ::wp_is_client_side_media_processing_enabled */ class Tests_Media_wpCrossOriginIsolation extends WP_UnitTestCase { /** * Original HTTP_USER_AGENT value. - * - * @var string|null */ - private $original_user_agent; + private ?string $original_user_agent; + + /** + * Original HTTP_HOST value. + */ + private ?string $original_http_host; + + /** + * Original HTTPS value. + */ + private ?string $original_https; + + /** + * Original $_GET['action'] value. + */ + private ?string $original_get_action; public function set_up() { parent::set_up(); - $this->original_user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : null; + $this->original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $this->original_http_host = $_SERVER['HTTP_HOST'] ?? null; + $this->original_https = $_SERVER['HTTPS'] ?? null; + $this->original_get_action = $_GET['action'] ?? null; } public function tear_down() { @@ -28,6 +45,24 @@ public function tear_down() { $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent; } + if ( null === $this->original_http_host ) { + unset( $_SERVER['HTTP_HOST'] ); + } else { + $_SERVER['HTTP_HOST'] = $this->original_http_host; + } + + if ( null === $this->original_https ) { + unset( $_SERVER['HTTPS'] ); + } else { + $_SERVER['HTTPS'] = $this->original_https; + } + + if ( null === $this->original_get_action ) { + unset( $_GET['action'] ); + } else { + $_GET['action'] = $this->original_get_action; + } + // Clean up any output buffers started during tests. while ( ob_get_level() > 1 ) { ob_end_clean(); @@ -124,6 +159,32 @@ public function test_does_not_start_output_buffer_for_safari() { $this->assertSame( $level_before, $level_after, 'Output buffer should not be started for Safari.' ); } + /** + * @ticket 64803 + */ + public function test_client_side_processing_disabled_on_non_secure_origin() { + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['HTTPS'] = ''; + + $this->assertFalse( + wp_is_client_side_media_processing_enabled(), + 'Client-side media processing should be disabled on non-secure, non-localhost origins.' + ); + } + + /** + * @ticket 64803 + */ + public function test_client_side_processing_enabled_on_localhost() { + $_SERVER['HTTP_HOST'] = 'localhost'; + $_SERVER['HTTPS'] = ''; + + $this->assertTrue( + wp_is_client_side_media_processing_enabled(), + 'Client-side media processing should be enabled on localhost.' + ); + } + /** * This test must run in a separate process because the output buffer * callback sends HTTP headers via header(), which would fail in the diff --git a/tests/phpunit/tests/post/nav-menu.php b/tests/phpunit/tests/post/nav-menu.php index d4ece1ff1776c..5ee9fb5f57097 100644 --- a/tests/phpunit/tests/post/nav-menu.php +++ b/tests/phpunit/tests/post/nav-menu.php @@ -1188,6 +1188,8 @@ public function test_wp_update_nav_menu_item_with_special_characters_in_category ) ); + $this->assertSame( 'Test Cat - "Pre-Slashed" Cat Name & >', $category->name ); + $category_item_id = wp_update_nav_menu_item( $this->menu_id, 0, @@ -1196,11 +1198,7 @@ public function test_wp_update_nav_menu_item_with_special_characters_in_category 'menu-item-object' => 'category', 'menu-item-object-id' => $category->term_id, 'menu-item-status' => 'publish', - /* - * Interestingly enough, if we use `$cat->name` for the menu item title, - * we won't be able to replicate the bug because it's in htmlentities form. - */ - 'menu-item-title' => $category_name, + 'menu-item-title' => $category->name, ) ); diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 93cd4211c93ba..79e9d23cf9dd3 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -194,6 +194,18 @@ public function tear_down() { parent::tear_down(); } + /** + * Enables client-side media processing and reinitializes the REST server + * so that the sideload and finalize routes are registered. + */ + private function enable_client_side_media_processing(): void { + add_filter( 'wp_client_side_media_processing_enabled', '__return_true' ); + + global $wp_rest_server; + $wp_rest_server = new Spy_REST_Server(); + do_action( 'rest_api_init', $wp_rest_server ); + } + public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/media', $routes ); @@ -2929,6 +2941,80 @@ public function test_upload_unsupported_image_type_with_filter() { $this->assertSame( 201, $response->get_status() ); } + /** + * Test that unsupported image type check is skipped when not generating sub-sizes. + * + * When the client handles image processing (generate_sub_sizes is false), + * the server should not check image editor support. + * + * Tests the permissions check directly with file params set, since the core + * check uses get_file_params() which is only populated for multipart uploads. + * + * @ticket 64836 + */ + public function test_upload_unsupported_image_type_skipped_when_not_generating_sub_sizes() { + wp_set_current_user( self::$author_id ); + + add_filter( 'wp_image_editors', '__return_empty_array' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'avif-lossy.avif', + 'type' => 'image/avif', + 'tmp_name' => self::$test_avif_file, + 'error' => 0, + 'size' => filesize( self::$test_avif_file ), + ), + ) + ); + $request->set_param( 'generate_sub_sizes', false ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $result = $controller->create_item_permissions_check( $request ); + + // Should pass because generate_sub_sizes is false (client handles processing). + $this->assertTrue( $result ); + } + + /** + * Test that unsupported image type check is enforced when generating sub-sizes. + * + * When the server handles image processing (generate_sub_sizes is true), + * the server should still check image editor support. + * + * Tests the permissions check directly with file params set, since the core + * check uses get_file_params() which is only populated for multipart uploads. + * + * @ticket 64836 + */ + public function test_upload_unsupported_image_type_enforced_when_generating_sub_sizes() { + wp_set_current_user( self::$author_id ); + + add_filter( 'wp_image_editors', '__return_empty_array' ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'avif-lossy.avif', + 'type' => 'image/avif', + 'tmp_name' => self::$test_avif_file, + 'error' => 0, + 'size' => filesize( self::$test_avif_file ), + ), + ) + ); + + $controller = new WP_REST_Attachments_Controller( 'attachment' ); + $result = $controller->create_item_permissions_check( $request ); + + // Should fail because the server needs to generate sub-sizes but can't. + $this->assertWPError( $result ); + $this->assertSame( 'rest_upload_image_type_not_supported', $result->get_error_code() ); + } + /** * Test that uploading an SVG image doesn't throw a `rest_upload_image_type_not_supported` error. * @@ -3162,6 +3248,8 @@ static function ( $data ) use ( &$captured_data ) { * @requires function imagejpeg */ public function test_sideload_scaled_image() { + $this->enable_client_side_media_processing(); + wp_set_current_user( self::$author_id ); // First, create an attachment. @@ -3215,6 +3303,8 @@ public function test_sideload_scaled_image() { * @requires function imagejpeg */ public function test_sideload_scaled_image_requires_auth() { + $this->enable_client_side_media_processing(); + wp_set_current_user( self::$author_id ); // Create an attachment. @@ -3244,6 +3334,8 @@ public function test_sideload_scaled_image_requires_auth() { * @ticket 64737 */ public function test_sideload_route_includes_scaled_enum() { + $this->enable_client_side_media_processing(); + $server = rest_get_server(); $routes = $server->get_routes(); @@ -3266,6 +3358,8 @@ public function test_sideload_route_includes_scaled_enum() { * @requires function imagejpeg */ public function test_sideload_scaled_unique_filename() { + $this->enable_client_side_media_processing(); + wp_set_current_user( self::$author_id ); // Create an attachment. @@ -3300,6 +3394,8 @@ public function test_sideload_scaled_unique_filename() { * @requires function imagejpeg */ public function test_sideload_scaled_unique_filename_conflict() { + $this->enable_client_side_media_processing(); + wp_set_current_user( self::$author_id ); // Create the first attachment. @@ -3343,4 +3439,106 @@ public function test_sideload_scaled_unique_filename_conflict() { $basename = wp_basename( $new_file ); $this->assertMatchesRegularExpression( '/canola-scaled-\d+\.jpg$/', $basename, 'Scaled filename should have numeric suffix when file conflicts with a different attachment.' ); } + + /** + * Tests that the finalize endpoint triggers wp_generate_attachment_metadata. + * + * @ticket 62243 + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + */ + public function test_finalize_item(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + $this->assertSame( 201, $response->get_status() ); + + // Track whether wp_generate_attachment_metadata filter fires. + $filter_metadata = null; + $filter_id = null; + $filter_context = null; + add_filter( + 'wp_generate_attachment_metadata', + function ( array $metadata, int $id, string $context ) use ( &$filter_metadata, &$filter_id, &$filter_context ) { + $filter_metadata = $metadata; + $filter_id = $id; + $filter_context = $context; + $metadata['foo'] = 'bar'; + return $metadata; + }, + 10, + 3 + ); + + // Call the finalize endpoint. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Finalize endpoint should return 200.' ); + $this->assertIsArray( $filter_metadata ); + $this->assertStringContainsString( 'canola', $filter_metadata['file'], 'Expected the canola image to have been had its metadata updated.' ); + $this->assertSame( $attachment_id, $filter_id, 'Expected the post ID to be passed to the filter.' ); + $this->assertSame( 'update', $filter_context, 'Filter context should be "update".' ); + $resulting_metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertIsArray( $resulting_metadata ); + $this->assertArrayHasKey( 'foo', $resulting_metadata, 'Expected new metadata key to have been added.' ); + $this->assertSame( 'bar', $resulting_metadata['foo'], 'Expected filtered metadata to be updated.' ); + } + + /** + * Tests that the finalize endpoint requires authentication. + * + * @ticket 62243 + * @covers WP_REST_Attachments_Controller::finalize_item + * @requires function imagejpeg + */ + public function test_finalize_item_requires_auth(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create an attachment. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + // Try finalizing without authentication. + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/finalize" ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_edit_image', $response, 401 ); + } + + /** + * Tests that the finalize endpoint returns error for invalid attachment ID. + * + * @ticket 62243 + * @covers WP_REST_Attachments_Controller::finalize_item + */ + public function test_finalize_item_invalid_id(): void { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + $invalid_id = PHP_INT_MAX; + $this->assertNull( get_post( $invalid_id ), 'Expected invalid ID to not exist for an existing post.' ); + $request = new WP_REST_Request( 'POST', "/wp/v2/media/$invalid_id/finalize" ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } } diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index d701d12f9dd68..212ddde70dd83 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -3330,6 +3330,111 @@ public function test_create_update_post_with_featured_media() { $this->assertSame( 0, (int) get_post_thumbnail_id( $new_post->ID ) ); } + /** + * Data provider for featured media link permission tests. + * + * @return array + */ + public function data_featured_media_link_permissions() { + return array( + 'unauthenticated user with draft parent attachment' => array( + 'attachment_parent_status' => 'draft', + 'attachment_status' => 'inherit', + 'user_id' => 0, + 'expect_link' => false, + ), + 'authenticated editor with draft parent attachment' => array( + 'attachment_parent_status' => 'draft', + 'attachment_status' => 'inherit', + 'user_id' => 'editor', + 'expect_link' => true, + ), + 'unauthenticated user with published attachment' => array( + 'attachment_parent_status' => null, + 'attachment_status' => 'publish', + 'user_id' => 0, + 'expect_link' => true, + ), + ); + } + + /** + * Tests that featured media links respect attachment permissions. + * + * @ticket 64183 + * @dataProvider data_featured_media_link_permissions + * + * @param string|null $attachment_parent_status Status of the attachment's parent post, or null for no parent. + * @param string $attachment_status Status to set on the attachment. + * @param int|string $user_id User ID (0 for unauthenticated) or 'editor' for editor role. + * @param bool $expect_link Whether the featured media link should be included. + */ + public function test_get_item_featured_media_link_permissions( $attachment_parent_status, $attachment_status, $user_id, $expect_link ) { + $file = DIR_TESTDATA . '/images/canola.jpg'; + + // Create attachment parent if needed. + $parent_post_id = 0; + if ( null !== $attachment_parent_status ) { + $parent_post_id = self::factory()->post->create( + array( + 'post_title' => 'Parent Post', + 'post_status' => $attachment_parent_status, + ) + ); + } + + // Create attachment. + $attachment_id = self::factory()->attachment->create_object( + $file, + $parent_post_id, + array( + 'post_mime_type' => 'image/jpeg', + ) + ); + + // Set attachment status if different from default. + if ( 'publish' === $attachment_status ) { + wp_update_post( + array( + 'ID' => $attachment_id, + 'post_status' => 'publish', + ) + ); + } + + // Create published post with featured media. + $published_post_id = self::factory()->post->create( + array( + 'post_title' => 'Published Post', + 'post_status' => 'publish', + ) + ); + set_post_thumbnail( $published_post_id, $attachment_id ); + + // Set current user. + if ( 'editor' === $user_id ) { + wp_set_current_user( self::$editor_id ); + } else { + wp_set_current_user( $user_id ); + } + + // Make request. + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $published_post_id ) ); + $response = rest_get_server()->dispatch( $request ); + $links = $response->get_links(); + + // Assert link presence based on expectation. + if ( $expect_link ) { + $this->assertArrayHasKey( 'https://api.w.org/featuredmedia', $links ); + $this->assertSame( + rest_url( '/wp/v2/media/' . $attachment_id ), + $links['https://api.w.org/featuredmedia'][0]['href'] + ); + } else { + $this->assertArrayNotHasKey( 'https://api.w.org/featuredmedia', $links ); + } + } + public function test_create_post_invalid_author() { wp_set_current_user( self::$editor_id ); diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 0e0e00b934359..89bf2c481c567 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -16,6 +16,9 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase { public function set_up() { parent::set_up(); + // Ensure client-side media processing is enabled so the sideload route is registered. + add_filter( 'wp_client_side_media_processing_enabled', '__return_true' ); + /** @var WP_REST_Server $wp_rest_server */ global $wp_rest_server; $wp_rest_server = new Spy_REST_Server(); @@ -110,6 +113,7 @@ public function test_expected_routes_in_schema() { '/wp/v2/media/(?P[\\d]+)/post-process', '/wp/v2/media/(?P[\\d]+)/edit', '/wp/v2/media/(?P[\\d]+)/sideload', + '/wp/v2/media/(?P[\\d]+)/finalize', '/wp/v2/blocks', '/wp/v2/blocks/(?P[\d]+)', '/wp/v2/blocks/(?P[\d]+)/autosaves', diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index ef9e72e6a6724..dd79885d2b16d 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -120,10 +120,6 @@ public function test_get_items() { 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php 'wp_enable_real_time_collaboration', - // Connectors API keys are registered in _wp_register_default_connector_settings() in wp-includes/connectors.php. - 'connectors_ai_anthropic_api_key', - 'connectors_ai_google_api_key', - 'connectors_ai_openai_api_key', ); if ( ! is_multisite() ) { diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1CategoriesController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1CategoriesController.php index 8a93c7a64047d..43525263ac5ba 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1CategoriesController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1CategoriesController.php @@ -6,7 +6,7 @@ * @covers WP_REST_Abilities_V1_Categories_Controller * * @group abilities-api - * @group rest-api + * @group restapi */ class Tests_REST_API_WpRestAbilitiesV1CategoriesController extends WP_UnitTestCase { diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index e64965242dc98..9ee564ef00069 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -6,7 +6,7 @@ * @covers WP_REST_Abilities_V1_List_Controller * * @group abilities-api - * @group rest-api + * @group restapi */ class Tests_REST_API_WpRestAbilitiesV1ListController extends WP_UnitTestCase { diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index 0c03d72dab8a5..609b7677d7b58 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -6,7 +6,7 @@ * @covers WP_REST_Abilities_V1_Run_Controller * * @group abilities-api - * @group rest-api + * @group restapi */ class Tests_REST_API_WpRestAbilitiesV1RunController extends WP_UnitTestCase { diff --git a/tests/phpunit/tests/template.php b/tests/phpunit/tests/template.php index e899ea2f06908..a79702554dc64 100644 --- a/tests/phpunit/tests/template.php +++ b/tests/phpunit/tests/template.php @@ -151,6 +151,7 @@ public function tear_down() { $registry->unregister( 'third-party/test' ); } + unset( $GLOBALS['_wp_tests_development_mode'] ); parent::tear_down(); } @@ -1477,9 +1478,17 @@ public function test_wp_load_classic_theme_block_styles_on_demand( string $theme /** * Data provider. * - * @return array + * @return array */ public function data_wp_hoist_late_printed_styles(): array { + $blocks_content = '
      This is only a test!
      '; + $early_common_styles = array( 'wp-img-auto-sizes-contain-inline-css', 'early-css', @@ -1487,12 +1496,14 @@ public function data_wp_hoist_late_printed_styles(): array { 'wp-emoji-styles-inline-css', ); - $common_late_in_head = array( - // Styles enqueued at wp_enqueue_scripts (priority 10). + // Styles enqueued at wp_enqueue_scripts (priority 10). + $common_at_wp_enqueue_scripts = array( 'normal-css', 'normal-inline-css', + ); - // Styles printed at wp_head priority 10. + $common_late_in_head = array( + // Styles printed at wp_head priority 101. 'wp-custom-css', ); @@ -1521,6 +1532,7 @@ public function data_wp_hoist_late_printed_styles(): array { // Hoisted. Enqueued by wp_enqueue_global_styles() which runs at wp_enqueue_scripts priority 10 and wp_footer priority 1. 'global-styles-inline-css', ), + $common_at_wp_enqueue_scripts, $common_late_in_head, $common_late_in_body ); @@ -1528,14 +1540,17 @@ public function data_wp_hoist_late_printed_styles(): array { return array( 'standard_classic_theme_config_with_min_styles_inlined' => array( 'set_up' => null, + 'content' => $blocks_content, 'inline_size_limit' => 0, 'expected_styles' => array( 'HEAD' => $common_expected_head_styles, 'BODY' => array(), ), ), + 'standard_classic_theme_config_with_max_styles_inlined' => array( 'set_up' => null, + 'content' => $blocks_content, 'inline_size_limit' => PHP_INT_MAX, 'expected_styles' => array( 'HEAD' => array_merge( @@ -1548,12 +1563,14 @@ public function data_wp_hoist_late_printed_styles(): array { 'custom-block-styles-css', 'global-styles-inline-css', ), + $common_at_wp_enqueue_scripts, $common_late_in_head, $common_late_in_body ), 'BODY' => array(), ), ), + 'classic_theme_styles_omitted' => array( 'set_up' => static function () { // Note that wp_enqueue_scripts is used instead of enqueue_block_assets because it runs again at the former action. @@ -1565,6 +1582,7 @@ static function () { 100 ); }, + 'content' => $blocks_content, 'inline_size_limit' => PHP_INT_MAX, 'expected_styles' => array( 'HEAD' => array_merge( @@ -1576,12 +1594,14 @@ static function () { 'custom-block-styles-css', 'global-styles-inline-css', ), + $common_at_wp_enqueue_scripts, $common_late_in_head, $common_late_in_body ), 'BODY' => array(), ), ), + 'no_styles_at_enqueued_block_assets' => array( 'set_up' => static function () { add_action( @@ -1593,6 +1613,7 @@ static function () { 100 ); }, + 'content' => $blocks_content, 'inline_size_limit' => PHP_INT_MAX, 'expected_styles' => array( 'HEAD' => array_merge( @@ -1603,21 +1624,23 @@ static function () { 'third-party-test-block-css', 'global-styles-inline-css', ), + $common_at_wp_enqueue_scripts, $common_late_in_head, $common_late_in_body ), 'BODY' => array(), ), ), + 'no_global_styles' => array( 'set_up' => static function () { - add_filter( - 'print_styles_array', - static function ( $handles ) { - return array_values( array_diff( $handles, array( 'global-styles' ) ) ); - } - ); + $dequeue = static function () { + wp_dequeue_style( 'global-styles' ); + }; + add_action( 'wp_enqueue_scripts', $dequeue, 1000 ); + add_action( 'wp_footer', $dequeue, 2 ); }, + 'content' => $blocks_content, 'inline_size_limit' => PHP_INT_MAX, 'expected_styles' => array( 'HEAD' => array_merge( @@ -1629,37 +1652,121 @@ static function ( $handles ) { 'third-party-test-block-css', 'custom-block-styles-css', ), + $common_at_wp_enqueue_scripts, $common_late_in_head, $common_late_in_body ), 'BODY' => array(), ), ), - 'standard_classic_theme_config_extra_block_library_inline_style' => array( + + 'standard_classic_theme_config_extra_block_library_inline_style_none_inlined' => array( 'set_up' => static function () { add_action( 'enqueue_block_assets', static function () { - wp_add_inline_style( 'wp-block-library', '/* Extra CSS which prevents empty inline style containing placeholder from being removed. */' ); + // Extra CSS which prevents empty inline style containing placeholder from being removed. + wp_add_inline_style( 'wp-block-library', '.wp-block-separator{ outline:solid 1px lime; }' ); } ); }, + 'content' => $blocks_content, 'inline_size_limit' => 0, 'expected_styles' => array( - 'HEAD' => ( function ( $expected_styles ) { - // Insert 'wp-block-library-inline-css' right after 'wp-block-library-css'. - $i = array_search( 'wp-block-library-css', $expected_styles, true ); - $this->assertIsInt( $i, 'Expected wp-block-library-css to be among the styles.' ); - array_splice( $expected_styles, $i + 1, 0, 'wp-block-library-inline-css' ); - return $expected_styles; - } )( $common_expected_head_styles ), + 'HEAD' => array_merge( + $early_common_styles, + array( + 'wp-block-library-css', + 'wp-block-separator-css', + 'wp-block-library-inline-css-extra', + 'classic-theme-styles-css', + 'third-party-test-block-css', + 'custom-block-styles-css', + 'global-styles-inline-css', + ), + $common_at_wp_enqueue_scripts, + $common_late_in_head, + $common_late_in_body + ), 'BODY' => array(), ), + 'assert' => function ( string $buffer, string $filtered_buffer ) { + $block_separator_core_style_span = null; + $block_separator_custom_style_span = null; + $processor = new class( $filtered_buffer ) extends WP_HTML_Tag_Processor { + public function get_span(): WP_HTML_Span { + $this->set_bookmark( 'here' ); + return $this->bookmarks['here']; + } + }; + while ( $processor->next_tag() ) { + if ( + $processor->get_tag() === 'LINK' && + $processor->get_attribute( 'rel' ) === 'stylesheet' && + $processor->get_attribute( 'id' ) === 'wp-block-separator-css' + ) { + $block_separator_core_style_span = $processor->get_span(); + } elseif ( + $processor->get_tag() === 'STYLE' && + $processor->get_attribute( 'id' ) === 'wp-block-library-inline-css-extra' && + str_contains( $processor->get_modifiable_text(), '.wp-block-separator{ outline:solid 1px lime; }' ) + ) { + $block_separator_custom_style_span = $processor->get_span(); + } + } + + $this->assertInstanceOf( WP_HTML_Span::class, $block_separator_core_style_span, 'Expected the block separator core style to be present.' ); + $this->assertInstanceOf( WP_HTML_Span::class, $block_separator_custom_style_span, 'Expected the block separator custom style to be present.' ); + $this->assertGreaterThan( $block_separator_core_style_span->start, $block_separator_custom_style_span->start, 'Expected the block separator custom style to appear after the block separator stylesheet.' ); + }, ), + + 'standard_classic_theme_config_extra_block_library_inline_style_all_inlined' => array( + 'set_up' => static function () { + add_action( + 'enqueue_block_assets', + static function () { + // Extra CSS which prevents empty inline style containing placeholder from being removed. + wp_add_inline_style( 'wp-block-library', '.wp-block-separator{ outline:solid 1px lime; }' ); + } + ); + }, + 'content' => $blocks_content, + 'inline_size_limit' => PHP_INT_MAX, + 'expected_styles' => array( + 'HEAD' => array_merge( + $early_common_styles, + array( + 'wp-block-library-inline-css', + 'wp-block-separator-inline-css', + 'wp-block-library-inline-css-extra', + 'classic-theme-styles-inline-css', + 'third-party-test-block-css', + 'custom-block-styles-css', + 'global-styles-inline-css', + ), + $common_at_wp_enqueue_scripts, + $common_late_in_head, + $common_late_in_body + ), + 'BODY' => array(), + ), + 'assert' => function ( string $buffer, string $filtered_buffer ) { + $block_separator_inline_style_start_tag = '