diff --git a/includes/create-theme/theme-patterns.php b/includes/create-theme/theme-patterns.php index 5d4b7c95..8f042b82 100644 --- a/includes/create-theme/theme-patterns.php +++ b/includes/create-theme/theme-patterns.php @@ -51,6 +51,12 @@ public static function strip_php_tags( $content ) { /** * Build a pattern .php file from a template stdClass. * + * The pattern header Title is taken from `$template->title` (the + * human-readable label). When that property is absent or empty the + * template slug is used as a fallback so the header is never blank. + * Any `* /` sequence in either value is escaped to `*/` to prevent + * PHP comment-block injection. + * * IMPORTANT: this function expects `$template->content` to be already * sanitised by the caller. The pipeline entry point is * `CBT_Theme_Templates::prepare_template_for_export`, which strips PHP @@ -62,10 +68,12 @@ public static function pattern_from_template( $template, $new_slug = null ) { $theme_slug = $new_slug ? $new_slug : wp_get_theme()->get( 'TextDomain' ); $template_slug = str_replace( '*/', '*/', $template->slug ); $pattern_slug = $theme_slug . '/' . $template_slug; + $raw_title = ! empty( $template->title ) ? $template->title : $template->slug; + $pattern_title = str_replace( '*/', '*/', $raw_title ); $pattern_content = <<name = sanitize_title_with_dashes( $pattern_post->post_title ); $pattern->slug = wp_get_theme()->get( 'TextDomain' ) . '/' . $pattern->name; $pattern_category_list = get_the_terms( $pattern->id, 'wp_pattern_category' ); - $pattern->categories = ! empty( $pattern_category_list ) ? join( ', ', wp_list_pluck( $pattern_category_list, 'name' ) ) : ''; + $pattern->categories = ! empty( $pattern_category_list ) ? join( ', ', wp_list_pluck( $pattern_category_list, 'slug' ) ) : ''; $pattern_title = str_replace( '*/', '*/', $pattern->title ); $pattern_categories = str_replace( '*/', '*/', $pattern->categories ); $safe_body = self::strip_php_tags( $pattern_post->post_content ); diff --git a/tests/test-theme-patterns.php b/tests/test-theme-patterns.php index dac9771a..a46ccb46 100644 --- a/tests/test-theme-patterns.php +++ b/tests/test-theme-patterns.php @@ -266,6 +266,115 @@ public function test_pattern_from_template_keeps_slug_escape_intact() { $this->assertStringNotContainsString( 'evil */ break', $result['content'] ); } + /** + * When $template->title is set, the pattern header must use that human-readable + * title instead of the machine slug. + * + * @covers CBT_Theme_Patterns::pattern_from_template + */ + public function test_pattern_from_template_uses_title_not_slug() { + $template = new stdClass(); + $template->slug = 'test-template'; + $template->title = 'Test Template Title'; + $template->content = '

safe

'; + + $result = CBT_Theme_Patterns::pattern_from_template( $template ); + + $this->assertStringContainsString( 'Title: Test Template Title', $result['content'] ); + $this->assertStringNotContainsString( 'Title: test-template', $result['content'] ); + } + + /** + * When $template->title is absent or empty, fall back to the slug so the + * header is never blank. + * + * @covers CBT_Theme_Patterns::pattern_from_template + */ + public function test_pattern_from_template_falls_back_to_slug_when_title_empty() { + $template = new stdClass(); + $template->slug = 'test-template'; + $template->title = ''; + $template->content = '

safe

'; + + $result = CBT_Theme_Patterns::pattern_from_template( $template ); + + $this->assertStringContainsString( 'Title: test-template', $result['content'] ); + } + + /** + * A `* /` sequence inside the title must be escaped to prevent PHP comment + * injection. + * + * @covers CBT_Theme_Patterns::pattern_from_template + */ + public function test_pattern_from_template_escapes_comment_closer_in_title() { + $template = new stdClass(); + $template->slug = 'some-template'; + $template->title = 'Evil */ Title'; + $template->content = '

safe

'; + + $result = CBT_Theme_Patterns::pattern_from_template( $template ); + + $this->assertStringContainsString( '*/', $result['content'] ); + $this->assertStringNotContainsString( 'Evil */ Title', $result['content'] ); + } + + /** + * Categories written into the pattern header must use the taxonomy slug + * (e.g. "category-slug") not the display name (e.g. "Category Name"), so that + * WordPress core can match patterns to the correct inserter category. + * + * @covers CBT_Theme_Patterns::pattern_from_wp_block + */ + public function test_pattern_from_wp_block_uses_category_slugs_not_names() { + // Register a pattern category whose name differs from its slug. + $term = wp_insert_term( 'Category Name', 'wp_pattern_category', array( 'slug' => 'category-slug' ) ); + $this->assertFalse( is_wp_error( $term ), 'Term creation should succeed' ); + + $post = $this->make_wp_block_post( '

safe

', 'Test Pattern' ); + + // Assign the category to the pattern post. + wp_set_object_terms( $post->ID, array( (int) $term['term_id'] ), 'wp_pattern_category' ); + + $pattern = CBT_Theme_Patterns::pattern_from_wp_block( $post ); + + // The header must contain the slug, not the display name. + $this->assertStringContainsString( 'Categories: category-slug', $pattern->content ); + $this->assertStringNotContainsString( 'Categories: Category Name', $pattern->content ); + + // Cleanup. + wp_delete_term( (int) $term['term_id'], 'wp_pattern_category' ); + } + + /** + * When a pattern has multiple categories, all slugs should be written + * (comma-separated) in the header. + * + * @covers CBT_Theme_Patterns::pattern_from_wp_block + */ + public function test_pattern_from_wp_block_uses_multiple_category_slugs() { + $term_a = wp_insert_term( 'Category One', 'wp_pattern_category', array( 'slug' => 'category-one-slug' ) ); + $term_b = wp_insert_term( 'Category Two', 'wp_pattern_category', array( 'slug' => 'category-two-slug' ) ); + + $this->assertFalse( is_wp_error( $term_a ), 'Term A creation should succeed' ); + $this->assertFalse( is_wp_error( $term_b ), 'Term B creation should succeed' ); + + $post = $this->make_wp_block_post( '

safe

', 'Test Pattern' ); + wp_set_object_terms( $post->ID, array( (int) $term_a['term_id'], (int) $term_b['term_id'] ), 'wp_pattern_category' ); + + $pattern = CBT_Theme_Patterns::pattern_from_wp_block( $post ); + + // Both slugs must appear; display names must not. + $this->assertStringContainsString( 'category-one-slug', $pattern->content ); + $this->assertStringContainsString( 'category-two-slug', $pattern->content ); + $this->assertStringNotContainsString( 'Category One', $pattern->content ); + $this->assertStringNotContainsString( 'Category Two', $pattern->content ); + + // Cleanup. + wp_delete_term( (int) $term_a['term_id'], 'wp_pattern_category' ); + wp_delete_term( (int) $term_b['term_id'], 'wp_pattern_category' ); + } + public function test_prepare_template_for_export_preserves_trusted_localize_markers() { // When localizeText=true is enabled, CBT_Theme_Templates::escape_text_in_template // injects trusted PHP esc_html_e(...) markers into the template body.