diff --git a/src/tools/templates.ts b/src/tools/templates.ts index 02997bd..fb36b9b 100644 --- a/src/tools/templates.ts +++ b/src/tools/templates.ts @@ -25,7 +25,7 @@ export const TEMPLATE_TOOLS = [ { name: 'list_templates', description: - 'List a block theme\'s templates (page layouts like "single", "archive") or template parts (reusable regions like "header", "footer"). Each row includes `wp_id` — non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. On a classic (non-block) theme, returns an empty list with a `note` explaining why.', + 'List the active theme\'s templates (page layouts like "single", "archive") or template parts (reusable regions like "header", "footer") — works on a theme without a full block-theme structure too, as long as it has real templates/parts. Each row includes `wp_id` — non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. Returns an empty list with a `note` only when there is truly nothing to list.', annotations: { ...READ_ANNOT, title: 'List templates' }, inputSchema: { type: 'object' as const, diff --git a/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs b/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs index f74a828..e6e5a15 100755 --- a/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs +++ b/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs @@ -53578,7 +53578,7 @@ var TEMPLATE_SOURCE_ENUM = ["theme", "plugin", "custom"]; var TEMPLATE_TOOLS = [ { name: "list_templates", - description: 'List a block theme\'s templates (page layouts like "single", "archive") or template parts (reusable regions like "header", "footer"). Each row includes `wp_id` \u2014 non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. On a classic (non-block) theme, returns an empty list with a `note` explaining why.', + description: 'List the active theme\'s templates (page layouts like "single", "archive") or template parts (reusable regions like "header", "footer") \u2014 works on a theme without a full block-theme structure too, as long as it has real templates/parts. Each row includes `wp_id` \u2014 non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. Returns an empty list with a `note` only when there is truly nothing to list.', annotations: { ...READ_ANNOT2, title: "List templates" }, inputSchema: { type: "object", diff --git a/wordpress-plugin/gk-block-mcp/includes/abilities/tools.manifest.json b/wordpress-plugin/gk-block-mcp/includes/abilities/tools.manifest.json index 9e7338d..00acc5c 100644 --- a/wordpress-plugin/gk-block-mcp/includes/abilities/tools.manifest.json +++ b/wordpress-plugin/gk-block-mcp/includes/abilities/tools.manifest.json @@ -2104,7 +2104,7 @@ "name": "list_templates", "ability": "gk-block-mcp/list-templates", "label": "List templates", - "description": "List a block theme's templates (page layouts like \"single\", \"archive\") or template parts (reusable regions like \"header\", \"footer\"). Each row includes `wp_id` — non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. On a classic (non-block) theme, returns an empty list with a `note` explaining why.", + "description": "List the active theme's templates (page layouts like \"single\", \"archive\") or template parts (reusable regions like \"header\", \"footer\") — works on a theme without a full block-theme structure too, as long as it has real templates/parts. Each row includes `wp_id` — non-null only when a database override shadows the theme file, which is what makes a template editable via update_template. Returns an empty list with a `note` only when there is truly nothing to list.", "input_schema": { "type": "object", "properties": { diff --git a/wordpress-plugin/gk-block-mcp/includes/class-template-manager.php b/wordpress-plugin/gk-block-mcp/includes/class-template-manager.php index 1c2040f..323dd4d 100644 --- a/wordpress-plugin/gk-block-mcp/includes/class-template-manager.php +++ b/wordpress-plugin/gk-block-mcp/includes/class-template-manager.php @@ -122,14 +122,6 @@ public function get_templates( array $args ) { return $type; } - if ( ! wp_is_block_theme() ) { - return array( - 'templates' => array(), - 'count' => 0, - 'note' => __( 'Active theme is not a block theme; no block templates exist.', 'gk-block-mcp' ), - ); - } - $query = array(); if ( 'wp_template_part' === $type && ! empty( $args['area'] ) ) { @@ -163,10 +155,20 @@ static function ( $template ) use ( $source ) { $formatted = array_map( array( $this, 'format_template_summary' ), $templates ); - return array( + $result = array( 'templates' => $formatted, 'count' => count( $formatted ), ); + + // A hybrid theme (wp_is_block_theme() false, e.g. no templates/index.html) + // can still have real templates/parts get_block_templates() finds via + // theme files or DB overrides, so an empty result alone doesn't mean + // "not a block theme" — only note that when it's also actually true. + if ( empty( $formatted ) && ! wp_is_block_theme() ) { + $result['note'] = __( 'Active theme is not a full block theme; only registered block templates/parts are listed.', 'gk-block-mcp' ); + } + + return $result; } /** @@ -254,14 +256,6 @@ public function update_template( $id, $type, array $args ) { return $type; } - if ( ! wp_is_block_theme() ) { - return new \WP_Error( - 'classic_theme', - __( 'Active theme is not a block theme; there are no block templates to edit.', 'gk-block-mcp' ), - array( 'status' => 400 ) - ); - } - $id = is_string( $id ) ? sanitize_text_field( $id ) : ''; if ( '' === $id ) { return new \WP_Error( @@ -281,8 +275,18 @@ public function update_template( $id, $type, array $args ) { ); } + // Gate on whether the id actually resolves, not wp_is_block_theme(): + // a hybrid theme (no templates/index.html) can still have real, + // resolvable templates/parts, and those are meaningful to edit. $template = get_block_template( $id, $type ); if ( ! $template ) { + if ( ! wp_is_block_theme() ) { + return new \WP_Error( + 'classic_theme', + __( 'Active theme is not a block theme; there are no block templates to edit.', 'gk-block-mcp' ), + array( 'status' => 400 ) + ); + } return new \WP_Error( 'not_found', sprintf( /* translators: %s: template id */ __( 'Template "%s" not found.', 'gk-block-mcp' ), $id ), @@ -375,14 +379,6 @@ public function reset_template( $id, $type = 'wp_template' ) { return $type; } - if ( ! wp_is_block_theme() ) { - return new \WP_Error( - 'classic_theme', - __( 'Active theme is not a block theme; there are no template overrides to reset.', 'gk-block-mcp' ), - array( 'status' => 400 ) - ); - } - $id = is_string( $id ) ? sanitize_text_field( $id ) : ''; if ( '' === $id ) { return new \WP_Error( @@ -392,8 +388,16 @@ public function reset_template( $id, $type = 'wp_template' ) { ); } + // Same resolution-based gate as update_template() — see its comment. $template = get_block_template( $id, $type ); if ( ! $template ) { + if ( ! wp_is_block_theme() ) { + return new \WP_Error( + 'classic_theme', + __( 'Active theme is not a block theme; there are no template overrides to reset.', 'gk-block-mcp' ), + array( 'status' => 400 ) + ); + } return new \WP_Error( 'not_found', sprintf( /* translators: %s: template id */ __( 'Template "%s" not found.', 'gk-block-mcp' ), $id ), diff --git a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php index 298ae22..7558d2b 100644 --- a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php +++ b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerTest.php @@ -95,6 +95,25 @@ private function find_block_theme() { return null; } + /** + * Register the fixture theme directory containing "hybrid-theme" (a + * theme with templates/ and parts/ files but no templates/index.html, + * so wp_is_block_theme() is false). A separate root from + * ensure_theme_root_resolvable()'s dummy one; by the time it's + * registered the "more than one root" workaround already applies, so + * this one just needs to contain the fixture. + * + * @return void + */ + private function register_hybrid_theme_root() { + register_theme_directory( dirname( __DIR__ ) . '/fixtures/themes' ); + // search_theme_directories() memoizes its scan in a function-local + // static for the rest of the process; by this point in the run + // something has always already forced that memoization without + // this root, so appending it here is invisible until forced. + wp_clean_themes_cache(); + } + /** * Find a formatted template row by slug. * @@ -294,4 +313,39 @@ public function test_get_template_invalid_type_returns_error() { $this->assertInstanceOf( \WP_Error::class, $result ); $this->assertSame( 'invalid_type', $result->get_error_code() ); } + + // ── get_templates(): hybrid theme ───────────────────────────────── + + /** + * A theme can have real templates and parts on disk without satisfying + * wp_is_block_theme() (which checks specifically for templates/index.html + * or block-templates/index.html). The old unconditional + * `! wp_is_block_theme()` short-circuit hid those templates/parts + * entirely and printed a note claiming none exist. + */ + public function test_get_templates_hybrid_theme_lists_real_templates_and_parts() { + $this->register_hybrid_theme_root(); + switch_theme( 'hybrid-theme' ); + $this->assertFalse( wp_is_block_theme(), 'Fixture must reproduce wp_is_block_theme() === false to exercise the hybrid case.' ); + + $templates = $this->tm->get_templates( array( 'type' => 'wp_template' ) ); + $this->assertNotNull( $this->find_by_slug( $templates['templates'], 'single' ) ); + $this->assertArrayNotHasKey( 'note', $templates ); + + $parts = $this->tm->get_templates( array( 'type' => 'wp_template_part' ) ); + $this->assertNotNull( $this->find_by_slug( $parts['templates'], 'footer' ) ); + $this->assertArrayNotHasKey( 'note', $parts ); + } + + /** + * The note is informational, not a blanket "any empty result" flag: an + * empty result on a real block theme (e.g. an area filter matching + * nothing) must not carry a "not a block theme" note that isn't true. + */ + public function test_get_templates_full_block_theme_empty_result_has_no_note() { + $result = $this->tm->get_templates( array( 'type' => 'wp_template_part', 'area' => 'no-such-area' ) ); + + $this->assertSame( array(), $result['templates'] ); + $this->assertArrayNotHasKey( 'note', $result ); + } } diff --git a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php index 19f1b9e..3f83464 100644 --- a/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php +++ b/wordpress-plugin/gk-block-mcp/tests/Templates/TemplateManagerWriteTest.php @@ -101,6 +101,19 @@ private function find_block_theme() { return null; } + /** + * Register the fixture theme directory containing "hybrid-theme". See + * TemplateManagerTest::register_hybrid_theme_root() for the rationale. + * + * @return void + */ + private function register_hybrid_theme_root() { + register_theme_directory( dirname( __DIR__ ) . '/fixtures/themes' ); + // See TemplateManagerTest::register_hybrid_theme_root() — forces + // search_theme_directories()'s memoized scan to pick this root up. + wp_clean_themes_cache(); + } + // ── Gate ─────────────────────────────────────────────────────────── /** @@ -473,4 +486,74 @@ public function test_update_template_rolls_back_new_override_when_area_term_assi ); $this->assertCount( 0, $matching->posts, 'A term-assignment failure must not leave an orphaned override post behind.' ); } + + // ── Hybrid theme (wp_is_block_theme() false, but a part resolves) ─── + + /** + * A hybrid theme's template part resolves via get_block_template(), + * so a gated write against it must succeed — the old unconditional + * `! wp_is_block_theme()` 400 guard blocked this even though the part + * genuinely renders on such a site. + */ + public function test_update_template_creates_override_for_hybrid_theme_template_part() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + $this->register_hybrid_theme_root(); + switch_theme( 'hybrid-theme' ); + $this->assertFalse( wp_is_block_theme(), 'Fixture must reproduce wp_is_block_theme() === false to exercise the hybrid case.' ); + + $result = $this->tm->update_template( + 'hybrid-theme//footer', + 'wp_template_part', + array( 'content' => '
Overridden footer
' ) + ); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['success'] ); + + $fetched = $this->tm->get_template( 'hybrid-theme//footer', 'wp_template_part' ); + $this->assertSame( 'custom', $fetched['source'] ); + $this->assertStringContainsString( 'Overridden footer', $fetched['content'] ); + } + + /** + * reset_template must be gated the same way — resolution, not + * wp_is_block_theme() — so it can revert the override this test just + * created on a hybrid theme. + */ + public function test_reset_template_reverts_hybrid_theme_override() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + $this->register_hybrid_theme_root(); + switch_theme( 'hybrid-theme' ); + + $updated = $this->tm->update_template( + 'hybrid-theme//footer', + 'wp_template_part', + array( 'content' => 'Overridden
' ) + ); + $this->assertTrue( $updated['success'] ); + + $reset = $this->tm->reset_template( 'hybrid-theme//footer', 'wp_template_part' ); + + $this->assertIsArray( $reset ); + $this->assertTrue( $reset['success'] ); + $this->assertNull( get_post( $updated['wp_id'] ) ); + } + + /** + * Unchanged regression: a genuinely classic theme (no templates/parts + * at all) still gets the specific, actionable "classic_theme" 400 — + * proves the resolution-based gate doesn't regress into a generic + * not_found for the case that guard exists to make clearer. + */ + public function test_update_template_classic_theme_still_returns_400_when_nothing_resolves() { + update_option( Template_Manager::ALLOW_TEMPLATE_EDITS_OPTION, '1' ); + switch_theme( 'default' ); + + $result = $this->tm->update_template( 'default//does-not-exist', 'wp_template', array( 'content' => 'x
' ) ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'classic_theme', $result->get_error_code() ); + $data = $result->get_error_data(); + $this->assertSame( 400, $data['status'] ); + } } diff --git a/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/parts/footer.html b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/parts/footer.html new file mode 100644 index 0000000..f588f70 --- /dev/null +++ b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/parts/footer.html @@ -0,0 +1,3 @@ + +Hybrid Footer Part
+ diff --git a/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/style.css b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/style.css new file mode 100644 index 0000000..783bf0e --- /dev/null +++ b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/style.css @@ -0,0 +1,7 @@ +/* +Theme Name: Hybrid Theme +Theme URI: https://wordpress.org/ +Description: For testing purposes only — a "hybrid" theme with no templates/index.html or block-templates/index.html (so wp_is_block_theme() is false) but real files under templates/ and parts/ (so get_block_templates() still finds them). +Version: 1.0.0 +Text Domain: hybrid-theme +*/ diff --git a/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/templates/single.html b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/templates/single.html new file mode 100644 index 0000000..6491009 --- /dev/null +++ b/wordpress-plugin/gk-block-mcp/tests/fixtures/themes/hybrid-theme/templates/single.html @@ -0,0 +1,3 @@ + +Hybrid Single Template
+