diff --git a/src/wp-includes/block-template-utils.php b/src/wp-includes/block-template-utils.php index 2c5b68707b..e0e64cb6f1 100644 --- a/src/wp-includes/block-template-utils.php +++ b/src/wp-includes/block-template-utils.php @@ -1,23 +1,495 @@ WP_TEMPLATE_PART_AREA_UNCATEGORIZED, + 'label' => __( 'General' ), + 'description' => __( + 'General templates often perform a specific role like displaying post content, and are not tied to any particular area.' + ), + 'icon' => 'layout', + 'area_tag' => 'div', + ), + array( + 'area' => WP_TEMPLATE_PART_AREA_HEADER, + 'label' => __( 'Header' ), + 'description' => __( + 'The Header template defines a page area that typically contains a title, logo, and main navigation.' + ), + 'icon' => 'header', + 'area_tag' => 'header', + ), + array( + 'area' => WP_TEMPLATE_PART_AREA_FOOTER, + 'label' => __( 'Footer' ), + 'description' => __( + 'The Footer template defines a page area that typically contains site credits, social links, or any other combination of blocks.' + ), + 'icon' => 'footer', + 'area_tag' => 'footer', + ), + ); + + /** + * Filters the list of allowed template part area values. + * + * @since 5.9.0 + * + * @param array $default_areas An array of supported area objects. + */ + return apply_filters( 'default_wp_template_part_areas', $default_area_definitions ); +} + + +/** + * Returns a filtered list of default template types, containing their + * localized titles and descriptions. + * + * @since 5.9.0 + * + * @return array The default template types. + */ +function get_default_block_template_types() { + $default_template_types = array( + 'index' => array( + 'title' => _x( 'Index', 'Template name' ), + 'description' => __( 'The default template used when no other template is available. This is a required template in WordPress.' ), + ), + 'home' => array( + 'title' => _x( 'Home', 'Template name' ), + 'description' => __( 'Template used for the main page that displays blog posts. This is the front page by default in WordPress. If a static front page is set, this is the template used for the page that contains the latest blog posts.' ), + ), + 'front-page' => array( + 'title' => _x( 'Front Page', 'Template name' ), + 'description' => __( 'Template used to render the front page of the site, whether it displays blog posts or a static page. The front page template takes precedence over the "Home" template.' ), + ), + 'singular' => array( + 'title' => _x( 'Singular', 'Template name' ), + 'description' => __( 'Template used for displaying single views of the content. This template is a fallback for the Single, Post, and Page templates, which take precedence when they exist.' ), + ), + 'single' => array( + 'title' => _x( 'Single Post', 'Template name' ), + 'description' => __( 'Template used to display a single blog post.' ), + ), + 'page' => array( + 'title' => _x( 'Page', 'Template name' ), + 'description' => __( 'Template used to display individual pages.' ), + ), + 'archive' => array( + 'title' => _x( 'Archive', 'Template name' ), + 'description' => __( 'The archive template displays multiple entries at once. It is used as a fallback for the Category, Author, and Date templates, which take precedence when they are available.' ), + ), + 'author' => array( + 'title' => _x( 'Author', 'Template name' ), + 'description' => __( 'Archive template used to display a list of posts from a single author.' ), + ), + 'category' => array( + 'title' => _x( 'Category', 'Template name' ), + 'description' => __( 'Archive template used to display a list of posts from the same category.' ), + ), + 'taxonomy' => array( + 'title' => _x( 'Taxonomy', 'Template name' ), + 'description' => __( 'Archive template used to display a list of posts from the same taxonomy.' ), + ), + 'date' => array( + 'title' => _x( 'Date', 'Template name' ), + 'description' => __( 'Archive template used to display a list of posts from a specific date.' ), + ), + 'tag' => array( + 'title' => _x( 'Tag', 'Template name' ), + 'description' => __( 'Archive template used to display a list of posts with a given tag.' ), + ), + 'attachment' => array( + 'title' => __( 'Media' ), + 'description' => __( 'Template used to display individual media items or attachments.' ), + ), + 'search' => array( + 'title' => _x( 'Search', 'Template name' ), + 'description' => __( 'Template used to display search results.' ), + ), + 'privacy-policy' => array( + 'title' => __( 'Privacy Policy' ), + 'description' => '', + ), + '404' => array( + 'title' => _x( '404', 'Template name' ), + 'description' => __( 'Template shown when no content is found.' ), + ), + ); + + /** + * Filters the list of template types. + * + * @since 5.9.0 + * + * @param array $default_template_types An array of template types, formatted as [ slug => [ title, description ] ]. + */ + return apply_filters( 'default_template_types', $default_template_types ); +} + +/** + * Checks whether the input 'area' is a supported value. + * Returns the input if supported, otherwise returns the 'uncategorized' value. + * + * @access private + * @since 5.9.0 + * + * @param string $type Template part area name. + * + * @return string Input if supported, else the uncategorized value. + */ +function _filter_block_template_part_area( $type ) { + $allowed_areas = array_map( + static function ( $item ) { + return $item['area']; + }, + get_allowed_block_template_part_areas() + ); + if ( in_array( $type, $allowed_areas, true ) ) { + return $type; + } + + $warning_message = sprintf( + /* translators: %1$s: Template area type, %2$s: the uncategorized template area value. */ + __( '"%1$s" is not a supported wp_template_part area value and has been added as "%2$s".' ), + $type, + WP_TEMPLATE_PART_AREA_UNCATEGORIZED + ); + trigger_error( $warning_message, E_USER_NOTICE ); + return WP_TEMPLATE_PART_AREA_UNCATEGORIZED; +} + +/** + * Finds all nested template part file paths in a theme's directory. + * + * @access private + * @since 5.9.0 + * + * @param string $base_directory The theme's file path. + * @return array $path_list A list of paths to all template part files. + */ +function _get_block_templates_paths( $base_directory ) { + $path_list = array(); + if ( file_exists( $base_directory ) ) { + $nested_files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $base_directory ) ); + $nested_html_files = new RegexIterator( $nested_files, '/^.+\.html$/i', RecursiveRegexIterator::GET_MATCH ); + foreach ( $nested_html_files as $path => $file ) { + $path_list[] = $path; + } + } + return $path_list; +} + +/** + * Retrieves the template file from the theme for a given slug. + * + * @access private + * @since 5.9.0 + * + * @param string $template_type wp_template or wp_template_part. + * @param string $slug template slug. + * + * @return array|null Template. + */ +function _get_block_template_file( $template_type, $slug ) { + if ( 'wp_template' !== $template_type && 'wp_template_part' !== $template_type ) { + return null; + } + + $template_base_paths = array( + 'wp_template' => 'block-templates', + 'wp_template_part' => 'block-template-parts', + ); + $themes = array( + get_stylesheet() => get_stylesheet_directory(), + get_template() => get_template_directory(), + ); + foreach ( $themes as $theme_slug => $theme_dir ) { + $file_path = $theme_dir . '/' . $template_base_paths[ $template_type ] . '/' . $slug . '.html'; + if ( file_exists( $file_path ) ) { + $new_template_item = array( + 'slug' => $slug, + 'path' => $file_path, + 'theme' => $theme_slug, + 'type' => $template_type, + ); + + if ( 'wp_template_part' === $template_type ) { + return _add_block_template_part_area_info( $new_template_item ); + } + + if ( 'wp_template' === $template_type ) { + return _add_block_template_info( $new_template_item ); + } + + return $new_template_item; + } + } + + return null; +} + +/** + * Retrieves the template files from the theme. + * + * @access private + * @since 5.9.0 + * + * @param string $template_type wp_template or wp_template_part. + * + * @return array Template. + */ +function _get_block_templates_files( $template_type ) { + if ( 'wp_template' !== $template_type && 'wp_template_part' !== $template_type ) { + return null; + } + + $template_base_paths = array( + 'wp_template' => 'block-templates', + 'wp_template_part' => 'block-template-parts', + ); + $themes = array( + get_stylesheet() => get_stylesheet_directory(), + get_template() => get_template_directory(), + ); + + $template_files = array(); + foreach ( $themes as $theme_slug => $theme_dir ) { + $theme_template_files = _get_block_templates_paths( $theme_dir . '/' . $template_base_paths[ $template_type ] ); + foreach ( $theme_template_files as $template_file ) { + $template_base_path = $template_base_paths[ $template_type ]; + $template_slug = substr( + $template_file, + // Starting position of slug. + strpos( $template_file, $template_base_path . DIRECTORY_SEPARATOR ) + 1 + strlen( $template_base_path ), + // Subtract ending '.html'. + -5 + ); + $new_template_item = array( + 'slug' => $template_slug, + 'path' => $template_file, + 'theme' => $theme_slug, + 'type' => $template_type, + ); + + if ( 'wp_template_part' === $template_type ) { + $template_files[] = _add_block_template_part_area_info( $new_template_item ); + } + + if ( 'wp_template' === $template_type ) { + $template_files[] = _add_block_template_info( $new_template_item ); + } + } + } + + return $template_files; +} + +/** + * Attempts to add custom template information to the template item. + * + * @access private + * @since 5.9.0 + * + * @param array $template_item Template to add information to (requires 'slug' field). + * @return array Template + */ +function _add_block_template_info( $template_item ) { + if ( ! WP_Theme_JSON_Resolver::theme_has_support() ) { + return $template_item; + } + + $theme_data = WP_Theme_JSON_Resolver::get_theme_data()->get_custom_templates(); + if ( isset( $theme_data[ $template_item['slug'] ] ) ) { + $template_item['title'] = $theme_data[ $template_item['slug'] ]['title']; + $template_item['postTypes'] = $theme_data[ $template_item['slug'] ]['postTypes']; + } + + return $template_item; +} + +/** + * Attempts to add the template part's area information to the input template. + * + * @access private + * @since 5.9.0 + * + * @param array $template_info Template to add information to (requires 'type' and 'slug' fields). + * + * @return array Template. + */ +function _add_block_template_part_area_info( $template_info ) { + if ( WP_Theme_JSON_Resolver::theme_has_support() ) { + $theme_data = WP_Theme_JSON_Resolver::get_theme_data()->get_template_parts(); + } + + if ( isset( $theme_data[ $template_info['slug'] ]['area'] ) ) { + $template_info['title'] = $theme_data[ $template_info['slug'] ]['title']; + $template_info['area'] = _filter_block_template_part_area( $theme_data[ $template_info['slug'] ]['area'] ); + } else { + $template_info['area'] = WP_TEMPLATE_PART_AREA_UNCATEGORIZED; + } + + return $template_info; +} + +/** + * Returns an array containing the references of + * the passed blocks and their inner blocks. + * + * @access private + * @since 5.9.0 + * + * @param array $blocks array of blocks. + * + * @return array block references to the passed blocks and their inner blocks. + */ +function _flatten_blocks( &$blocks ) { + $all_blocks = array(); + $queue = array(); + foreach ( $blocks as &$block ) { + $queue[] = &$block; + } + + while ( count( $queue ) > 0 ) { + $block = &$queue[0]; + array_shift( $queue ); + $all_blocks[] = &$block; + + if ( ! empty( $block['innerBlocks'] ) ) { + foreach ( $block['innerBlocks'] as &$inner_block ) { + $queue[] = &$inner_block; + } + } + } + + return $all_blocks; +} + +/** + * Parses wp_template content and injects the current theme's + * stylesheet as a theme attribute into each wp_template_part + * + * @access private + * @since 5.9.0 + * + * @param string $template_content serialized wp_template content. + * + * @return string Updated wp_template content. + */ +function _inject_theme_attribute_in_block_template_content( $template_content ) { + $has_updated_content = false; + $new_content = ''; + $template_blocks = parse_blocks( $template_content ); + + $blocks = _flatten_blocks( $template_blocks ); + foreach ( $blocks as &$block ) { + if ( + 'core/template-part' === $block['blockName'] && + ! isset( $block['attrs']['theme'] ) + ) { + $block['attrs']['theme'] = wp_get_theme()->get_stylesheet(); + $has_updated_content = true; + } + } + + if ( $has_updated_content ) { + foreach ( $template_blocks as &$block ) { + $new_content .= serialize_block( $block ); + } + + return $new_content; + } + + return $template_content; +} + +/** + * Build a unified template object based on a theme file. + * + * @access private + * @since 5.9.0 + * + * @param array $template_file Theme file. + * @param array $template_type wp_template or wp_template_part. + * + * @return WP_Block_Template Template. + */ +function _build_block_template_result_from_file( $template_file, $template_type ) { + $default_template_types = get_default_block_template_types(); + $template_content = file_get_contents( $template_file['path'] ); + $theme = wp_get_theme()->get_stylesheet(); + + $template = new WP_Block_Template(); + $template->id = $theme . '//' . $template_file['slug']; + $template->theme = $theme; + $template->content = _inject_theme_attribute_in_block_template_content( $template_content ); + $template->slug = $template_file['slug']; + $template->source = 'theme'; + $template->type = $template_type; + $template->title = ! empty( $template_file['title'] ) ? $template_file['title'] : $template_file['slug']; + $template->status = 'publish'; + $template->has_theme_file = true; + $template->is_custom = true; + + if ( 'wp_template' === $template_type && isset( $default_template_types[ $template_file['slug'] ] ) ) { + $template->description = $default_template_types[ $template_file['slug'] ]['description']; + $template->title = $default_template_types[ $template_file['slug'] ]['title']; + $template->is_custom = false; + } + + if ( 'wp_template' === $template_type && isset( $template_file['postTypes'] ) ) { + $template->post_types = $template_file['postTypes']; + } + + if ( 'wp_template_part' === $template_type && isset( $template_file['area'] ) ) { + $template->area = $template_file['area']; + } + + return $template; +} + /** * Build a unified template object based a post Object. * * @access private - * @since 5.8.0 + * @since 5.9.0 * * @param WP_Post $post Template post. * * @return WP_Block_Template|WP_Error Template. */ -function _build_template_result_from_post( $post ) { - $terms = get_the_terms( $post, 'wp_theme' ); +function _build_block_template_result_from_post( $post ) { + $default_template_types = get_default_block_template_types(); + $terms = get_the_terms( $post, 'wp_theme' ); if ( is_wp_error( $terms ) ) { return $terms; @@ -27,7 +499,9 @@ function _build_template_result_from_post( $post ) { return new WP_Error( 'template_missing_theme', __( 'No theme is defined for this template.' ) ); } - $theme = $terms[0]->name; + $theme = $terms[0]->name; + $has_theme_file = wp_get_theme()->get_stylesheet() === $theme && + null !== _get_block_template_file( $post->post_type, $post->post_name ); $template = new WP_Block_Template(); $template->wp_id = $post->ID; @@ -40,7 +514,19 @@ function _build_template_result_from_post( $post ) { $template->description = $post->post_excerpt; $template->title = $post->post_title; $template->status = $post->post_status; - $template->has_theme_file = false; + $template->has_theme_file = $has_theme_file; + $template->is_custom = true; + + if ( 'wp_template' === $post->post_type && isset( $default_template_types[ $template->slug ] ) ) { + $template->is_custom = false; + } + + if ( 'wp_template_part' === $post->post_type ) { + $type_terms = get_the_terms( $post, 'wp_template_part_area' ); + if ( ! is_wp_error( $type_terms ) && false !== $type_terms ) { + $template->area = $type_terms[0]->name; + } + } return $template; } @@ -53,13 +539,40 @@ function _build_template_result_from_post( $post ) { * @param array $query { * Optional. Arguments to retrieve templates. * - * @type array $slug__in List of slugs to include. - * @type int $wp_id Post ID of customized template. + * @type array $slug__in List of slugs to include. + * @type int $wp_id Post ID of customized template. + * @type string $area A 'wp_template_part_area' taxonomy value to filter by (for wp_template_part template type only). + * @type string $post_type Post type to get the templates for. * } - * @param string $template_type Optional. The template type (post type). Default 'wp_template'. - * @return WP_Block_Template[] Block template objects. + * @param array $template_type wp_template or wp_template_part. + * + * @return array Templates. */ function get_block_templates( $query = array(), $template_type = 'wp_template' ) { + /** + * Filters the block templates array before the query takes place. + * + * Return a non-null value to bypass the WordPress queries. + * + * @since 5.9 + * + * @param WP_Block_Template[]|null $block_templates Return an array of block templates to short-circuit the default query, + * or null to allow WP to run it's normal queries. + * @param array $query { + * Optional. Arguments to retrieve templates. + * + * @type array $slug__in List of slugs to include. + * @type int $wp_id Post ID of customized template. + * @type string $post_type Post type to get the templates for. + * } + * @param array $template_type wp_template or wp_template_part. + */ + $templates = apply_filters( 'pre_get_block_templates', null, $query, $template_type ); + if ( ! is_null( $templates ) ) { + return $templates; + } + + $post_type = isset( $query['post_type'] ) ? $query['post_type'] : ''; $wp_query_args = array( 'post_status' => array( 'auto-draft', 'draft', 'publish' ), 'post_type' => $template_type, @@ -74,11 +587,20 @@ function get_block_templates( $query = array(), $template_type = 'wp_template' ) ), ); + if ( 'wp_template_part' === $template_type && isset( $query['area'] ) ) { + $wp_query_args['tax_query'][] = array( + 'taxonomy' => 'wp_template_part_area', + 'field' => 'name', + 'terms' => $query['area'], + ); + $wp_query_args['tax_query']['relation'] = 'AND'; + } + if ( isset( $query['slug__in'] ) ) { $wp_query_args['post_name__in'] = $query['slug__in']; } - // This is only needed for the regular templates CPT listing and editor. + // This is only needed for the regular templates/template parts CPT listing and editor. if ( isset( $query['wp_id'] ) ) { $wp_query_args['p'] = $query['wp_id']; } else { @@ -88,14 +610,66 @@ function get_block_templates( $query = array(), $template_type = 'wp_template' ) $template_query = new WP_Query( $wp_query_args ); $query_result = array(); foreach ( $template_query->posts as $post ) { - $template = _build_template_result_from_post( $post ); + $template = _build_block_template_result_from_post( $post ); - if ( ! is_wp_error( $template ) ) { - $query_result[] = $template; + if ( is_wp_error( $template ) ) { + continue; + } + + if ( $post_type && ! $template->is_custom ) { + continue; + } + + $query_result[] = $template; + } + + if ( ! isset( $query['wp_id'] ) ) { + $template_files = _get_block_templates_files( $template_type ); + foreach ( $template_files as $template_file ) { + $template = _build_block_template_result_from_file( $template_file, $template_type ); + + if ( $post_type && ! $template->is_custom ) { + continue; + } + + if ( $post_type && + isset( $template->post_types ) && + ! in_array( $post_type, $template->post_types, true ) + ) { + continue; + } + + $is_not_custom = false === array_search( + wp_get_theme()->get_stylesheet() . '//' . $template_file['slug'], + array_column( $query_result, 'id' ), + true + ); + $fits_slug_query = + ! isset( $query['slug__in'] ) || in_array( $template_file['slug'], $query['slug__in'], true ); + $fits_area_query = + ! isset( $query['area'] ) || $template_file['area'] === $query['area']; + $should_include = $is_not_custom && $fits_slug_query && $fits_area_query; + if ( $should_include ) { + $query_result[] = $template; + } } } - return $query_result; + /** + * Filters the array of queried block templates array after they've been fetched. + * + * @since 5.9 + * + * @param WP_Block_Template[] $query_result Array of found block templates. + * @param array $query { + * Optional. Arguments to retrieve templates. + * + * @type array $slug__in List of slugs to include. + * @type int $wp_id Post ID of customized template. + * } + * @param array $template_type wp_template or wp_template_part. + */ + return apply_filters( 'get_block_templates', $query_result, $query, $template_type ); } /** @@ -104,10 +678,29 @@ function get_block_templates( $query = array(), $template_type = 'wp_template' ) * @since 5.8.0 * * @param string $id Template unique identifier (example: theme_slug//template_slug). - * @param string $template_type Optional. The template type (post type). Default 'wp_template'. + * @param array $template_type Optional. Template type: `'wp_template'` or '`wp_template_part'`. + * Default `'wp_template'`. + * * @return WP_Block_Template|null Template. */ function get_block_template( $id, $template_type = 'wp_template' ) { + /** + * Filters the block templates array before the query takes place. + * + * Return a non-null value to bypass the WordPress queries. + * + * @since 5.9.0 + * + * @param WP_Block_Template|null $block_template Return block template object to short-circuit the default query, + * or null to allow WP to run its normal queries. + * @param string $id Template unique identifier (example: theme_slug//template_slug). + * @param array $template_type Template type: `'wp_template'` or '`wp_template_part'`. + */ + $block_template = apply_filters( 'pre_get_block_template', null, $id, $template_type ); + if ( ! is_null( $block_template ) ) { + return $block_template; + } + $parts = explode( '//', $id, 2 ); if ( count( $parts ) < 2 ) { return null; @@ -131,12 +724,116 @@ function get_block_template( $id, $template_type = 'wp_template' ) { $posts = $template_query->posts; if ( count( $posts ) > 0 ) { - $template = _build_template_result_from_post( $posts[0] ); + $template = _build_block_template_result_from_post( $posts[0] ); if ( ! is_wp_error( $template ) ) { return $template; } } - return null; + $block_template = get_block_file_template( $id, $template_type ); + + /** + * Filters the array of queried block templates array after they've been fetched. + * + * @since 5.9 + * + * @param WP_Block_Template $block_template The found block template. + * @param string $id Template unique identifier (example: theme_slug//template_slug). + * @param array $template_type Template type: `'wp_template'` or '`wp_template_part'`. + */ + return apply_filters( 'get_block_template', $block_template, $id, $template_type ); +} + +/** + * Retrieves a single unified template object using its id. + * + * @since 5.9.0 + * + * @param string $id Template unique identifier (example: theme_slug//template_slug). + * @param array $template_type Optional. Template type: `'wp_template'` or '`wp_template_part'`. + * Default `'wp_template'`. + */ +function get_block_file_template( $id, $template_type = 'wp_template' ) { + /** + * Filters the block templates array before the query takes place. + * + * Return a non-null value to bypass the WordPress queries. + * + * + * @since 5.9.0 + * + * @param WP_Block_Template|null $block_template Return block template object to short-circuit the default query, + * or null to allow WP to run its normal queries. + * @param string $id Template unique identifier (example: theme_slug//template_slug). + * @param array $template_type Template type: `'wp_template'` or '`wp_template_part'`. + */ + $block_template = apply_filters( 'pre_get_block_file_template', null, $id, $template_type ); + if ( ! is_null( $block_template ) ) { + return $block_template; + } + + $parts = explode( '//', $id, 2 ); + if ( count( $parts ) < 2 ) { + /** This filter is documented at the end of this function */ + return apply_filters( 'get_block_file_template', null, $id, $template_type ); + } + list( $theme, $slug ) = $parts; + + if ( wp_get_theme()->get_stylesheet() !== $theme ) { + /** This filter is documented at the end of this function */ + return apply_filters( 'get_block_file_template', null, $id, $template_type ); + } + + $template_file = _get_block_template_file( $template_type, $slug ); + if ( null === $template_file ) { + /** This filter is documented at the end of this function */ + return apply_filters( 'get_block_file_template', null, $id, $template_type ); + } + + $block_template = _build_block_template_result_from_file( $template_file, $template_type ); + + /** + * Filters the array of queried block templates array after they've been fetched. + * + * @since 5.9.0 + * + * @param WP_Block_Template $block_template The found block template. + * @param string $id Template unique identifier (example: theme_slug//template_slug). + * @param array $template_type Template type: `'wp_template'` or '`wp_template_part'`. + */ + return apply_filters( 'get_block_file_template', $block_template, $id, $template_type ); +} + +/** + * Print a template-part. + * + * @since 5.9.0 + * + * @param string $part The template-part to print. Use "header" or "footer". + */ +function block_template_part( $part ) { + $template_part = get_block_template( get_stylesheet() . '//' . $part, 'wp_template_part' ); + if ( ! $template_part || empty( $template_part->content ) ) { + return; + } + echo do_blocks( $template_part->content ); +} + +/** + * Print the header template-part. + * + * @since 5.9.0 + */ +function block_header_area() { + block_template_part( 'header' ); +} + +/** + * Print the footer template-part. + * + * @since 5.9.0 + */ +function block_footer_area() { + block_template_part( 'footer' ); } diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 5b855e6502..f94a60d921 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -1154,6 +1154,8 @@ function build_query_vars_from_query_block( $block, $page ) { * * It's used in QueryPaginationNext and QueryPaginationPrevious blocks. * + * @since 5.9.0 + * * @param WP_Block $block Block instance. * @param boolean $is_next Flag for hanlding `next/previous` blocks. * diff --git a/src/wp-includes/blocks/index.php b/src/wp-includes/blocks/index.php index b702cdf03c..d3aa3f6d7d 100644 --- a/src/wp-includes/blocks/index.php +++ b/src/wp-includes/blocks/index.php @@ -37,6 +37,7 @@ require ABSPATH . WPINC . '/blocks/site-logo.php'; require ABSPATH . WPINC . '/blocks/site-title.php'; require ABSPATH . WPINC . '/blocks/social-link.php'; require ABSPATH . WPINC . '/blocks/tag-cloud.php'; +require ABSPATH . WPINC . '/blocks/template-part.php'; /** * Registers core block types using metadata files. diff --git a/src/wp-includes/blocks/template-part.php b/src/wp-includes/blocks/template-part.php new file mode 100644 index 0000000000..36fb948ad3 --- /dev/null +++ b/src/wp-includes/blocks/template-part.php @@ -0,0 +1,155 @@ +get_stylesheet() === $attributes['theme'] + ) { + $template_part_id = $attributes['theme'] . '//' . $attributes['slug']; + $template_part_query = new WP_Query( + array( + 'post_type' => 'wp_template_part', + 'post_status' => 'publish', + 'post_name__in' => array( $attributes['slug'] ), + 'tax_query' => array( + array( + 'taxonomy' => 'wp_theme', + 'field' => 'slug', + 'terms' => $attributes['theme'], + ), + ), + 'posts_per_page' => 1, + 'no_found_rows' => true, + ) + ); + $template_part_post = $template_part_query->have_posts() ? $template_part_query->next_post() : null; + if ( $template_part_post ) { + // A published post might already exist if this template part was customized elsewhere + // or if it's part of a customized template. + $content = $template_part_post->post_content; + $area_terms = get_the_terms( $template_part_post, 'wp_template_part_area' ); + if ( ! is_wp_error( $area_terms ) && false !== $area_terms ) { + $area = $area_terms[0]->name; + } + } else { + // Else, if the template part was provided by the active theme, + // render the corresponding file content. + $template_part_file_path = get_theme_file_path( '/block-template-parts/' . $attributes['slug'] . '.html' ); + if ( 0 === validate_file( $attributes['slug'] ) && file_exists( $template_part_file_path ) ) { + $content = _inject_theme_attribute_in_block_template_content( file_get_contents( $template_part_file_path ) ); + } + } + } + + if ( is_null( $content ) && is_user_logged_in() ) { + if ( ! isset( $attributes['slug'] ) ) { + // If there is no slug this is a placeholder and we dont want to return any message. + return; + } + return sprintf( + /* translators: %s: Template part slug. */ + __( 'Template part has been deleted or is unavailable: %s' ), + $attributes['slug'] + ); + } + + if ( isset( $seen_ids[ $template_part_id ] ) ) { + // WP_DEBUG_DISPLAY must only be honored when WP_DEBUG. This precedent + // is set in `wp_debug_mode()`. + $is_debug = defined( 'WP_DEBUG' ) && WP_DEBUG && + defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY; + + return $is_debug ? + // translators: Visible only in the front end, this warning takes the place of a faulty block. + __( '[block rendering halted]' ) : + ''; + } + + // Run through the actions that are typically taken on the_content. + $seen_ids[ $template_part_id ] = true; + $content = do_blocks( $content ); + unset( $seen_ids[ $template_part_id ] ); + $content = wptexturize( $content ); + $content = convert_smilies( $content ); + $content = shortcode_unautop( $content ); + $content = wp_filter_content_tags( $content ); + $content = do_shortcode( $content ); + + // Handle embeds for block template parts. + global $wp_embed; + $content = $wp_embed->autoembed( $content ); + + if ( empty( $attributes['tagName'] ) ) { + $defined_areas = get_allowed_block_template_part_areas(); + $area_tag = 'div'; + foreach ( $defined_areas as $defined_area ) { + if ( $defined_area['area'] === $area && isset( $defined_area['area_tag'] ) ) { + $area_tag = $defined_area['area_tag']; + } + } + $html_tag = $area_tag; + } else { + $html_tag = esc_attr( $attributes['tagName'] ); + } + $wrapper_attributes = get_block_wrapper_attributes(); + + return "<$html_tag $wrapper_attributes>" . str_replace( ']]>', ']]>', $content ) . ""; +} + +/** + * Returns an array of variation objects for the template part block. + * + * @return array Array containing the block variation objects. + */ +function build_template_part_block_variations() { + $variations = array(); + $defined_areas = get_allowed_block_template_part_areas(); + foreach ( $defined_areas as $area ) { + if ( 'uncategorized' !== $area['area'] ) { + $variations[] = array( + 'name' => $area['area'], + 'title' => $area['label'], + 'description' => $area['description'], + 'attributes' => array( + 'area' => $area['area'], + ), + 'scope' => array( 'inserter' ), + 'icon' => $area['icon'], + ); + } + } + return $variations; +} + +/** + * Registers the `core/template-part` block on the server. + */ +function register_block_core_template_part() { + register_block_type_from_metadata( + __DIR__ . '/template-part', + array( + 'render_callback' => 'render_block_core_template_part', + 'variations' => build_template_part_block_variations(), + ) + ); +} +add_action( 'init', 'register_block_core_template_part' ); diff --git a/src/wp-includes/blocks/template-part/block.json b/src/wp-includes/blocks/template-part/block.json new file mode 100644 index 0000000000..7e85b0f6aa --- /dev/null +++ b/src/wp-includes/blocks/template-part/block.json @@ -0,0 +1,35 @@ +{ + "apiVersion": 2, + "name": "core/template-part", + "title": "Template Part", + "category": "theme", + "description": "Edit the different global regions of your site, like the header, footer, sidebar, or create your own.", + "textdomain": "default", + "attributes": { + "slug": { + "type": "string" + }, + "theme": { + "type": "string" + }, + "tagName": { + "type": "string" + }, + "area": { + "type": "string" + } + }, + "supports": { + "align": true, + "html": false, + "color": { + "gradients": true, + "link": true + }, + "spacing": { + "padding": true + }, + "__experimentalLayout": true + }, + "editorStyle": "wp-block-template-part-editor" +} diff --git a/src/wp-includes/class-wp-block-template.php b/src/wp-includes/class-wp-block-template.php index 704a2afc45..a4938c07cf 100644 --- a/src/wp-includes/class-wp-block-template.php +++ b/src/wp-includes/class-wp-block-template.php @@ -100,4 +100,13 @@ class WP_Block_Template { * @var boolean */ public $has_theme_file; + + /** + * Whether a template is a custom template. + * + * @since 5.9.0 + * + * @var boolean + */ + public $is_custom = true; } diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index cd80d24d3c..894e0bfa0f 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -601,7 +601,7 @@ class WP_Theme_JSON { */ public function get_custom_templates() { $custom_templates = array(); - if ( ! isset( $this->theme_json['customTemplates'] ) ) { + if ( ! isset( $this->theme_json['customTemplates'] ) || ! is_array( $this->theme_json['customTemplates'] ) ) { return $custom_templates; } @@ -625,7 +625,7 @@ class WP_Theme_JSON { */ public function get_template_parts() { $template_parts = array(); - if ( ! isset( $this->theme_json['templateParts'] ) ) { + if ( ! isset( $this->theme_json['templateParts'] ) || ! is_array( $this->theme_json['templateParts'] ) ) { return $template_parts; } diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index c4a88b69d2..5783d5cd0f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -664,6 +664,7 @@ add_filter( 'user_has_cap', 'wp_maybe_grant_site_health_caps', 1, 4 ); // Block Templates CPT and Rendering add_filter( 'render_block_context', '_block_template_render_without_post_block_context' ); add_filter( 'pre_wp_unique_post_slug', 'wp_filter_wp_template_unique_post_slug', 10, 5 ); +add_action( 'save_post_wp_template_part', 'wp_set_unique_slug_on_create_template_part' ); add_action( 'wp_footer', 'the_block_template_skip_link' ); add_action( 'setup_theme', 'wp_enable_block_templates' ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index f578e4ce35..07ce72b42d 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -365,6 +365,64 @@ function create_initial_post_types() { ) ); + register_post_type( + 'wp_template_part', + array( + 'labels' => array( + 'name' => __( 'Template Parts' ), + 'singular_name' => __( 'Template Part' ), + 'add_new' => _x( 'Add New', 'Template Part' ), + 'add_new_item' => __( 'Add New Template Part' ), + 'new_item' => __( 'New Template Part' ), + 'edit_item' => __( 'Edit Template Part' ), + 'view_item' => __( 'View Template Part' ), + 'all_items' => __( 'All Template Parts' ), + 'search_items' => __( 'Search Template Parts' ), + 'parent_item_colon' => __( 'Parent Template Part:' ), + 'not_found' => __( 'No template parts found.' ), + 'not_found_in_trash' => __( 'No template parts found in Trash.' ), + 'archives' => __( 'Template part archives' ), + 'insert_into_item' => __( 'Insert into template part' ), + 'uploaded_to_this_item' => __( 'Uploaded to this template part' ), + 'filter_items_list' => __( 'Filter template parts list' ), + 'items_list_navigation' => __( 'Template parts list navigation' ), + 'items_list' => __( 'Template parts list' ), + ), + 'description' => __( 'Template parts to include in your templates.' ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + 'has_archive' => false, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'rewrite' => false, + 'rest_base' => 'template-parts', + 'rest_controller_class' => 'WP_REST_Templates_Controller', + 'map_meta_cap' => true, + 'capabilities' => array( + 'create_posts' => 'edit_theme_options', + 'delete_posts' => 'edit_theme_options', + 'delete_others_posts' => 'edit_theme_options', + 'delete_private_posts' => 'edit_theme_options', + 'delete_published_posts' => 'edit_theme_options', + 'edit_posts' => 'edit_theme_options', + 'edit_others_posts' => 'edit_theme_options', + 'edit_private_posts' => 'edit_theme_options', + 'edit_published_posts' => 'edit_theme_options', + 'publish_posts' => 'edit_theme_options', + 'read' => 'edit_theme_options', + 'read_private_posts' => 'edit_theme_options', + ), + 'supports' => array( + 'title', + 'slug', + 'excerpt', + 'editor', + 'revisions', + ), + ) + ); + register_post_type( 'wp_global_styles', array( diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php index e7712363f7..f3d406c095 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php @@ -156,6 +156,9 @@ class WP_REST_Templates_Controller extends WP_REST_Controller { if ( isset( $request['area'] ) ) { $query['area'] = $request['area']; } + if ( isset( $request['post_type'] ) ) { + $query['post_type'] = $request['post_type']; + } $templates = array(); foreach ( get_block_templates( $query, $this->post_type ) as $template ) { @@ -187,7 +190,11 @@ class WP_REST_Templates_Controller extends WP_REST_Controller { * @return WP_REST_Response|WP_Error */ public function get_item( $request ) { - $template = get_block_template( $request['id'], $this->post_type ); + if ( isset( $request['source'] ) && 'theme' === $request['source'] ) { + $template = get_block_file_template( $request['id'], $this->post_type ); + } else { + $template = get_block_template( $request['id'], $this->post_type ); + } if ( ! $template ) { return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.' ), array( 'status' => 404 ) ); @@ -222,6 +229,11 @@ class WP_REST_Templates_Controller extends WP_REST_Controller { return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.' ), array( 'status' => 404 ) ); } + if ( isset( $request['source'] ) && 'theme' === $request['source'] ) { + wp_delete_post( $template->wp_id, true ); + return $this->prepare_item_for_response( get_block_file_template( $request['id'], $this->post_type ), $request ); + } + $changes = $this->prepare_item_for_database( $request ); if ( 'custom' === $template->source ) { @@ -395,6 +407,16 @@ class WP_REST_Templates_Controller extends WP_REST_Controller { $changes->post_excerpt = $template->description; } + if ( 'wp_template_part' === $this->post_type ) { + if ( isset( $request['area'] ) ) { + $changes->tax_input['wp_template_part_area'] = _filter_block_template_part_area( $request['area'] ); + } elseif ( null !== $template && 'custom' !== $template->source && $template->area ) { + $changes->tax_input['wp_template_part_area'] = _filter_block_template_part_area( $template->area ); + } elseif ( ! $template->area ) { + $changes->tax_input['wp_template_part_area'] = WP_TEMPLATE_PART_AREA_UNCATEGORIZED; + } + } + return $changes; } @@ -502,16 +524,25 @@ class WP_REST_Templates_Controller extends WP_REST_Controller { * Retrieves the query params for the posts collection. * * @since 5.8.0 + * @since 5.9.0 Added `'area'` and `'post_type'`. * * @return array Collection parameters. */ public function get_collection_params() { return array( - 'context' => $this->get_context_param(), - 'wp_id' => array( + 'context' => $this->get_context_param(), + 'wp_id' => array( 'description' => __( 'Limit to the specified post id.' ), 'type' => 'integer', ), + 'area' => array( + 'description' => __( 'Limit to the specified template part area.' ), + 'type' => 'string', + ), + 'post_type' => array( + 'description' => __( 'Post type to get the templates for.' ), + 'type' => 'string', + ), ); } @@ -519,6 +550,7 @@ class WP_REST_Templates_Controller extends WP_REST_Controller { * Retrieves the block type' schema, conforming to JSON Schema. * * @since 5.8.0 + * @since 5.9.0 Added `'area'`. * * @return array Item schema data. */ @@ -596,6 +628,14 @@ class WP_REST_Templates_Controller extends WP_REST_Controller { ), ); + if ( 'wp_template_part' === $this->post_type ) { + $schema['properties']['area'] = array( + 'description' => __( 'Where the template part is intended for use (header, footer, etc.)' ), + 'type' => 'string', + 'context' => array( 'embed', 'view', 'edit' ), + ); + } + $this->schema = $schema; return $this->add_additional_fields_schema( $this->schema ); diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 79a5bdab52..36ec58cd38 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -18,6 +18,7 @@ * avoid registering rewrite rules before the {@see 'init'} action. * * @since 2.8.0 + * @since 5.9.0 Added `'wp_template_part_area'` taxonomy. * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. */ @@ -175,7 +176,7 @@ function create_initial_taxonomies() { register_taxonomy( 'wp_theme', - array( 'wp_template', 'wp_global_styles' ), + array( 'wp_template', 'wp_template_part', 'wp_global_styles' ), array( 'public' => false, 'hierarchical' => false, @@ -191,6 +192,25 @@ function create_initial_taxonomies() { 'show_in_rest' => false, ) ); + + register_taxonomy( + 'wp_template_part_area', + array( 'wp_template_part' ), + array( + 'public' => false, + 'hierarchical' => false, + 'labels' => array( + 'name' => __( 'Template Part Areas' ), + 'singular_name' => __( 'Template Part Area' ), + ), + 'query_var' => false, + 'rewrite' => false, + 'show_ui' => false, + '_builtin' => true, + 'show_in_nav_menus' => false, + 'show_in_rest' => false, + ) + ); } /** diff --git a/src/wp-includes/theme-templates.php b/src/wp-includes/theme-templates.php index c7466312cb..467dcac941 100644 --- a/src/wp-includes/theme-templates.php +++ b/src/wp-includes/theme-templates.php @@ -1,5 +1,35 @@ post_status ) { + return; + } + + if ( ! $post->post_name ) { + wp_update_post( + array( + 'ID' => $post_id, + 'post_name' => 'custom_slug_' . uniqid(), + ) + ); + } + + $terms = get_the_terms( $post_id, 'wp_theme' ); + if ( ! is_array( $terms ) || ! count( $terms ) ) { + wp_set_post_terms( $post_id, wp_get_theme()->get_stylesheet(), 'wp_theme' ); + } +} + /** * Generates a unique slug for templates. * @@ -14,7 +44,7 @@ * @return string The original, desired slug. */ function wp_filter_wp_template_unique_post_slug( $override_slug, $slug, $post_ID, $post_status, $post_type ) { - if ( 'wp_template' !== $post_type ) { + if ( 'wp_template' !== $post_type && 'wp_template_part' !== $post_type ) { return $override_slug; } diff --git a/tests/phpunit/data/templates/template.html b/tests/phpunit/data/templates/template.html new file mode 100644 index 0000000000..f792c12b5c --- /dev/null +++ b/tests/phpunit/data/templates/template.html @@ -0,0 +1,3 @@ + +

Just a paragraph

+ \ No newline at end of file diff --git a/tests/phpunit/includes/functions.php b/tests/phpunit/includes/functions.php index a2ad83c7de..02d6ba343b 100644 --- a/tests/phpunit/includes/functions.php +++ b/tests/phpunit/includes/functions.php @@ -337,5 +337,6 @@ function _unhook_block_registration() { remove_action( 'init', 'register_block_core_tag_cloud' ); remove_action( 'init', 'register_core_block_types_from_metadata' ); remove_action( 'init', 'register_block_core_legacy_widget' ); + remove_action( 'init', 'register_block_core_template_part' ); } tests_add_filter( 'init', '_unhook_block_registration', 1000 ); diff --git a/tests/phpunit/tests/block-template-utils.php b/tests/phpunit/tests/block-template-utils.php index a6242de614..714885319a 100644 --- a/tests/phpunit/tests/block-template-utils.php +++ b/tests/phpunit/tests/block-template-utils.php @@ -6,12 +6,34 @@ */ /** - * Tests for the Block Template Loader abstraction layer. + * Tests for the Block Templates abstraction layer. */ class Block_Template_Utils_Test extends WP_UnitTestCase { private static $post; + private static $template_part_post; public static function wpSetUpBeforeClass() { + // We may need a block theme. + // switch_theme( 'tt1-blocks' ); + + // Set up a template post corresponding to a different theme. + // We do this to ensure resolution and slug creation works as expected, + // even with another post of that same name present for another theme. + $args = array( + 'post_type' => 'wp_template', + 'post_name' => 'my_template', + 'post_title' => 'My Template', + 'post_content' => 'Content', + 'post_excerpt' => 'Description of my template', + 'tax_input' => array( + 'wp_theme' => array( + 'this-theme-should-not-resolve', + ), + ), + ); + self::$post = self::factory()->post->create_and_get( $args ); + wp_set_post_terms( self::$post->ID, 'this-theme-should-not-resolve', 'wp_theme' ); + // Set up template post. $args = array( 'post_type' => 'wp_template', @@ -27,6 +49,26 @@ class Block_Template_Utils_Test extends WP_UnitTestCase { ); self::$post = self::factory()->post->create_and_get( $args ); wp_set_post_terms( self::$post->ID, get_stylesheet(), 'wp_theme' ); + + // Set up template part post. + $template_part_args = array( + 'post_type' => 'wp_template_part', + 'post_name' => 'my_template_part', + 'post_title' => 'My Template Part', + 'post_content' => 'Content', + 'post_excerpt' => 'Description of my template part', + 'tax_input' => array( + 'wp_theme' => array( + get_stylesheet(), + ), + 'wp_template_part_area' => array( + WP_TEMPLATE_PART_AREA_HEADER, + ), + ), + ); + self::$template_part_post = self::factory()->post->create_and_get( $template_part_args ); + wp_set_post_terms( self::$template_part_post->ID, WP_TEMPLATE_PART_AREA_HEADER, 'wp_template_part_area' ); + wp_set_post_terms( self::$template_part_post->ID, get_stylesheet(), 'wp_theme' ); } public static function wpTearDownAfterClass() { @@ -34,20 +76,143 @@ class Block_Template_Utils_Test extends WP_UnitTestCase { } public function test_build_template_result_from_post() { - $template = _build_template_result_from_post( + $template = _build_block_template_result_from_post( self::$post, 'wp_template' ); $this->assertNotWPError( $template ); - $this->assertSame( get_stylesheet() . '//my_template', $template->id ); - $this->assertSame( get_stylesheet(), $template->theme ); - $this->assertSame( 'my_template', $template->slug ); - $this->assertSame( 'publish', $template->status ); - $this->assertSame( 'custom', $template->source ); - $this->assertSame( 'My Template', $template->title ); - $this->assertSame( 'Description of my template', $template->description ); - $this->assertSame( 'wp_template', $template->type ); + $this->assertEquals( get_stylesheet() . '//my_template', $template->id ); + $this->assertEquals( get_stylesheet(), $template->theme ); + $this->assertEquals( 'my_template', $template->slug ); + $this->assertEquals( 'publish', $template->status ); + $this->assertEquals( 'custom', $template->source ); + $this->assertEquals( 'My Template', $template->title ); + $this->assertEquals( 'Description of my template', $template->description ); + $this->assertEquals( 'wp_template', $template->type ); + + // Test template parts. + $template_part = _build_block_template_result_from_post( + self::$template_part_post, + 'wp_template_part' + ); + $this->assertNotWPError( $template_part ); + $this->assertEquals( get_stylesheet() . '//my_template_part', $template_part->id ); + $this->assertEquals( get_stylesheet(), $template_part->theme ); + $this->assertEquals( 'my_template_part', $template_part->slug ); + $this->assertEquals( 'publish', $template_part->status ); + $this->assertEquals( 'custom', $template_part->source ); + $this->assertEquals( 'My Template Part', $template_part->title ); + $this->assertEquals( 'Description of my template part', $template_part->description ); + $this->assertEquals( 'wp_template_part', $template_part->type ); + $this->assertEquals( WP_TEMPLATE_PART_AREA_HEADER, $template_part->area ); + } + + function test_build_block_template_result_from_file() { + $template = _build_block_template_result_from_file( + array( + 'slug' => 'single', + 'path' => __DIR__ . '/../data/templates/template.html', + ), + 'wp_template' + ); + + $this->assertEquals( get_stylesheet() . '//single', $template->id ); + $this->assertEquals( get_stylesheet(), $template->theme ); + $this->assertEquals( 'single', $template->slug ); + $this->assertEquals( 'publish', $template->status ); + $this->assertEquals( 'theme', $template->source ); + $this->assertEquals( 'Single Post', $template->title ); + $this->assertEquals( 'Template used to display a single blog post.', $template->description ); + $this->assertEquals( 'wp_template', $template->type ); + + // Test template parts. + $template_part = _build_block_template_result_from_file( + array( + 'slug' => 'header', + 'path' => __DIR__ . '/../data/templates/template.html', + 'area' => WP_TEMPLATE_PART_AREA_HEADER, + ), + 'wp_template_part' + ); + $this->assertEquals( get_stylesheet() . '//header', $template_part->id ); + $this->assertEquals( get_stylesheet(), $template_part->theme ); + $this->assertEquals( 'header', $template_part->slug ); + $this->assertEquals( 'publish', $template_part->status ); + $this->assertEquals( 'theme', $template_part->source ); + $this->assertEquals( 'header', $template_part->title ); + $this->assertEquals( '', $template_part->description ); + $this->assertEquals( 'wp_template_part', $template_part->type ); + $this->assertEquals( WP_TEMPLATE_PART_AREA_HEADER, $template_part->area ); + } + + function test_inject_theme_attribute_in_block_template_content() { + $theme = get_stylesheet(); + $content_without_theme_attribute = ''; + $template_content = _inject_theme_attribute_in_block_template_content( + $content_without_theme_attribute, + $theme + ); + $expected = sprintf( + '', + get_stylesheet() + ); + $this->assertEquals( $expected, $template_content ); + + $content_without_theme_attribute_nested = ''; + $template_content = _inject_theme_attribute_in_block_template_content( + $content_without_theme_attribute_nested, + $theme + ); + $expected = sprintf( + '', + get_stylesheet() + ); + $this->assertEquals( $expected, $template_content ); + + // Does not inject theme when there is an existing theme attribute. + $content_with_existing_theme_attribute = ''; + $template_content = _inject_theme_attribute_in_block_template_content( + $content_with_existing_theme_attribute, + $theme + ); + $this->assertEquals( $content_with_existing_theme_attribute, $template_content ); + + // Does not inject theme when there is no template part. + $content_with_no_template_part = ''; + $template_content = _inject_theme_attribute_in_block_template_content( + $content_with_no_template_part, + $theme + ); + $this->assertEquals( $content_with_no_template_part, $template_content ); + } + + /** + * Should retrieve the template from the theme files. + */ + function test_get_block_template_from_file() { + $this->markTestIncomplete(); + // Requires switching to a block theme. + /* $id = get_stylesheet() . '//' . 'index'; + $template = get_block_template( $id, 'wp_template' ); + $this->assertEquals( $id, $template->id ); + $this->assertEquals( get_stylesheet(), $template->theme ); + $this->assertEquals( 'index', $template->slug ); + $this->assertEquals( 'publish', $template->status ); + $this->assertEquals( 'theme', $template->source ); + $this->assertEquals( 'wp_template', $template->type ); + + // Test template parts. + $id = get_stylesheet() . '//' . 'header'; + $template = get_block_template( $id, 'wp_template_part' ); + $this->assertEquals( $id, $template->id ); + $this->assertEquals( get_stylesheet(), $template->theme ); + $this->assertEquals( 'header', $template->slug ); + $this->assertEquals( 'publish', $template->status ); + $this->assertEquals( 'theme', $template->source ); + $this->assertEquals( 'wp_template_part', $template->type ); + $this->assertEquals( WP_TEMPLATE_PART_AREA_HEADER, $template->area ); + */ } /** @@ -56,16 +221,27 @@ class Block_Template_Utils_Test extends WP_UnitTestCase { public function test_get_block_template_from_post() { $id = get_stylesheet() . '//' . 'my_template'; $template = get_block_template( $id, 'wp_template' ); - $this->assertSame( $id, $template->id ); - $this->assertSame( get_stylesheet(), $template->theme ); - $this->assertSame( 'my_template', $template->slug ); - $this->assertSame( 'publish', $template->status ); - $this->assertSame( 'custom', $template->source ); - $this->assertSame( 'wp_template', $template->type ); + $this->assertEquals( $id, $template->id ); + $this->assertEquals( get_stylesheet(), $template->theme ); + $this->assertEquals( 'my_template', $template->slug ); + $this->assertEquals( 'publish', $template->status ); + $this->assertEquals( 'custom', $template->source ); + $this->assertEquals( 'wp_template', $template->type ); + + // Test template parts. + $id = get_stylesheet() . '//' . 'my_template_part'; + $template = get_block_template( $id, 'wp_template_part' ); + $this->assertEquals( $id, $template->id ); + $this->assertEquals( get_stylesheet(), $template->theme ); + $this->assertEquals( 'my_template_part', $template->slug ); + $this->assertEquals( 'publish', $template->status ); + $this->assertEquals( 'custom', $template->source ); + $this->assertEquals( 'wp_template_part', $template->type ); + $this->assertEquals( WP_TEMPLATE_PART_AREA_HEADER, $template->area ); } /** - * Should retrieve block templates. + * Should retrieve block templates (file and CPT) */ public function test_get_block_templates() { function get_template_ids( $templates ) { @@ -84,14 +260,53 @@ class Block_Template_Utils_Test extends WP_UnitTestCase { // Avoid testing the entire array because the theme might add/remove templates. $this->assertContains( get_stylesheet() . '//' . 'my_template', $template_ids ); + // The result might change in a block theme. + // $this->assertContains( get_stylesheet() . '//' . 'index', $template_ids ); + // Filter by slug. $templates = get_block_templates( array( 'slug__in' => array( 'my_template' ) ), 'wp_template' ); $template_ids = get_template_ids( $templates ); - $this->assertSame( array( get_stylesheet() . '//' . 'my_template' ), $template_ids ); + $this->assertEquals( array( get_stylesheet() . '//' . 'my_template' ), $template_ids ); // Filter by CPT ID. $templates = get_block_templates( array( 'wp_id' => self::$post->ID ), 'wp_template' ); $template_ids = get_template_ids( $templates ); - $this->assertSame( array( get_stylesheet() . '//' . 'my_template' ), $template_ids ); + $this->assertEquals( array( get_stylesheet() . '//' . 'my_template' ), $template_ids ); + + // Filter template part by area. + // Requires a block theme. + /*$templates = get_block_templates( array( 'area' => WP_TEMPLATE_PART_AREA_HEADER ), 'wp_template_part' ); + $template_ids = get_template_ids( $templates ); + $this->assertEquals( + array( + get_stylesheet() . '//' . 'my_template_part', + get_stylesheet() . '//' . 'header', + ), + $template_ids + ); + */ + } + + /** + * Should flatten nested blocks + */ + function test_flatten_blocks() { + $content_template_part_inside_group = ''; + $blocks = parse_blocks( $content_template_part_inside_group ); + $actual = _flatten_blocks( $blocks ); + $expected = array( $blocks[0], $blocks[0]['innerBlocks'][0] ); + $this->assertEquals( $expected, $actual ); + + $content_template_part_inside_group_inside_group = ''; + $blocks = parse_blocks( $content_template_part_inside_group_inside_group ); + $actual = _flatten_blocks( $blocks ); + $expected = array( $blocks[0], $blocks[0]['innerBlocks'][0], $blocks[0]['innerBlocks'][0]['innerBlocks'][0] ); + $this->assertEquals( $expected, $actual ); + + $content_without_inner_blocks = ''; + $blocks = parse_blocks( $content_without_inner_blocks ); + $actual = _flatten_blocks( $blocks ); + $expected = array( $blocks[0] ); + $this->assertEquals( $expected, $actual ); } } diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 36f1184b6d..a81b5b21e2 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -132,6 +132,12 @@ class WP_Test_REST_Schema_Initialization extends WP_Test_REST_TestCase { '/wp/v2/block-types/(?P[a-zA-Z0-9_-]+)', '/wp/v2/block-types/(?P[a-zA-Z0-9_-]+)/(?P[a-zA-Z0-9_-]+)', '/wp/v2/settings', + '/wp/v2/template-parts', + '/wp/v2/template-parts/(?P[\/\w-]+)', + '/wp/v2/template-parts/(?P[\d]+)/autosaves', + '/wp/v2/template-parts/(?P[\d]+)/autosaves/(?P[\d]+)', + '/wp/v2/template-parts/(?P[\d]+)/revisions', + '/wp/v2/template-parts/(?P[\d]+)/revisions/(?P[\d]+)', '/wp/v2/templates', '/wp/v2/templates/(?P[\/\w-]+)', '/wp/v2/templates/(?P[\d]+)/autosaves', diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 9d152920bc..cd327ec468 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -4164,6 +4164,16 @@ mockedApiResponse.Schema = { "description": "Limit to the specified post id.", "type": "integer", "required": false + }, + "area": { + "description": "Limit to the specified template part area.", + "type": "string", + "required": false + }, + "post_type": { + "description": "Post type to get the templates for.", + "type": "string", + "required": false } } }, @@ -4585,6 +4595,478 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/template-parts": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "required": false + }, + "wp_id": { + "description": "Limit to the specified post id.", + "type": "integer", + "required": false + }, + "area": { + "description": "Limit to the specified template part area.", + "type": "string", + "required": false + }, + "post_type": { + "description": "Post type to get the templates for.", + "type": "string", + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "args": { + "slug": { + "description": "Unique slug identifying the template.", + "type": "string", + "minLength": 1, + "pattern": "[a-zA-Z_\\-]+", + "required": true + }, + "theme": { + "description": "Theme identifier for the template.", + "type": "string", + "required": false + }, + "content": { + "default": "", + "description": "Content of template.", + "type": [ + "object", + "string" + ], + "required": false + }, + "title": { + "default": "", + "description": "Title of template.", + "type": [ + "object", + "string" + ], + "required": false + }, + "description": { + "default": "", + "description": "Description of template.", + "type": "string", + "required": false + }, + "status": { + "default": "publish", + "description": "Status of template.", + "type": "string", + "required": false + }, + "area": { + "description": "Where the template part is intended for use (header, footer, etc.)", + "type": "string", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/template-parts" + } + ] + } + }, + "/wp/v2/template-parts/(?P[\\/\\w-]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "id": { + "description": "The id of a template", + "type": "string", + "required": false + } + } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "args": { + "slug": { + "description": "Unique slug identifying the template.", + "type": "string", + "minLength": 1, + "pattern": "[a-zA-Z_\\-]+", + "required": false + }, + "theme": { + "description": "Theme identifier for the template.", + "type": "string", + "required": false + }, + "content": { + "description": "Content of template.", + "type": [ + "object", + "string" + ], + "required": false + }, + "title": { + "description": "Title of template.", + "type": [ + "object", + "string" + ], + "required": false + }, + "description": { + "description": "Description of template.", + "type": "string", + "required": false + }, + "status": { + "description": "Status of template.", + "type": "string", + "required": false + }, + "area": { + "description": "Where the template part is intended for use (header, footer, etc.)", + "type": "string", + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "force": { + "type": "boolean", + "default": false, + "description": "Whether to bypass Trash and force deletion.", + "required": false + } + } + } + ] + }, + "/wp/v2/template-parts/(?P[\\d]+)/revisions": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by object attribute.", + "type": "string", + "default": "date", + "enum": [ + "date", + "id", + "include", + "relevance", + "slug", + "include_slugs", + "title" + ], + "required": false + } + } + } + ] + }, + "/wp/v2/template-parts/(?P[\\d]+)/revisions/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "id": { + "description": "Unique identifier for the revision.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "id": { + "description": "Unique identifier for the revision.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Required to be true, as revisions do not support trashing.", + "required": false + } + } + } + ] + }, + "/wp/v2/template-parts/(?P[\\d]+)/autosaves": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the autosave.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "args": { + "parent": { + "description": "The ID for the parent of the autosave.", + "type": "integer", + "required": false + }, + "slug": { + "description": "Unique slug identifying the template.", + "type": "string", + "minLength": 1, + "pattern": "[a-zA-Z_\\-]+", + "required": false + }, + "theme": { + "description": "Theme identifier for the template.", + "type": "string", + "required": false + }, + "content": { + "description": "Content of template.", + "type": [ + "object", + "string" + ], + "required": false + }, + "title": { + "description": "Title of template.", + "type": [ + "object", + "string" + ], + "required": false + }, + "description": { + "description": "Description of template.", + "type": "string", + "required": false + }, + "status": { + "description": "Status of template.", + "type": "string", + "required": false + }, + "area": { + "description": "Where the template part is intended for use (header, footer, etc.)", + "type": "string", + "required": false + } + } + } + ] + }, + "/wp/v2/template-parts/(?P[\\d]+)/autosaves/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the autosave.", + "type": "integer", + "required": false + }, + "id": { + "description": "The ID for the autosave.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + } + ] + }, "/wp/v2/types": { "namespace": "wp/v2", "methods": [ @@ -8610,6 +9092,34 @@ mockedApiResponse.TypesCollection = { } ] } + }, + "wp_template_part": { + "description": "Template parts to include in your templates.", + "hierarchical": false, + "name": "Template Parts", + "slug": "wp_template_part", + "taxonomies": [], + "rest_base": "template-parts", + "rest_namespace": "wp/v2", + "_links": { + "collection": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/types" + } + ], + "wp:items": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/template-parts" + } + ], + "curies": [ + { + "name": "wp", + "href": "https://api.w.org/{rel}", + "templated": true + } + ] + } } }; diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js index 2934732ab3..9604e1b6b9 100644 --- a/tools/webpack/blocks.js +++ b/tools/webpack/blocks.js @@ -53,6 +53,7 @@ module.exports = function( env = { environment: 'production', watch: false, buil 'site-title', 'social-link', 'tag-cloud', + 'template-part', ]; const blockFolders = [ 'audio',