From fc6349e97d755f887760cce6d9362c9460a93377 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 24 Jul 2026 08:53:01 -0400 Subject: [PATCH 1/6] fix(enrichers): bake a monospace fallback into code-block-pro font-family The code-block-pro enricher emitted `font-family:` with no generic family, so a custom CBP font (e.g. Code-Pro-JetBrains-Mono, which is not a loaded webfont) fell back to the browser default serif. Append the monospace stack when the value has no generic family keyword, mirroring what the CBP editor bakes; idempotent, so re-runs never double-append. Claude-Session: https://claude.ai/code/session_017AMC5bVpEM9G3WtXbXtmwj --- .../unit/enrichers/cbp-enricher.test.ts | 62 +++++++++++++++++++ src/enrichers.ts | 20 +++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/__tests__/unit/enrichers/cbp-enricher.test.ts b/src/__tests__/unit/enrichers/cbp-enricher.test.ts index 2025dc1..a3aa6da 100644 --- a/src/__tests__/unit/enrichers/cbp-enricher.test.ts +++ b/src/__tests__/unit/enrichers/cbp-enricher.test.ts @@ -145,6 +145,68 @@ describe('enrichBlock — Code Block Pro', () => { expect(result.innerHTML).toContain('color:#d8dee9ff'); }); + /** + * A custom fontFamily value (a CBP font-name like `Code-Pro-JetBrains-Mono`) + * is not a loaded webfont, so a bare `font-family:Code-Pro-JetBrains-Mono` + * makes browsers fall back to the default serif. The real CBP editor bakes a + * full monospace stack; the enricher must append the same generic fallback so + * a custom name still renders as monospace. + */ + it('appends a monospace fallback stack to a custom fontFamily', async () => { + const block: BlockDef = { + name: 'kevinbatdorf/code-block-pro', + attributes: { + code: 'const a = 1;', + language: 'javascript', + fontFamily: 'Code-Pro-JetBrains-Mono', + }, + }; + const result = await enrichBlock(block); + expect(result.innerHTML).toContain( + 'font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace', + ); + }); + + /** + * Idempotency: a fontFamily that already ends in a generic family keyword + * (`Menlo,monospace`) is emitted UNCHANGED — no appended stack, no doubled + * `monospace`. Re-running the enricher must never keep growing the value. + */ + it('leaves a fontFamily that already ends in a generic family unchanged', async () => { + const block: BlockDef = { + name: 'kevinbatdorf/code-block-pro', + attributes: { + code: 'const a = 1;', + language: 'javascript', + fontFamily: 'Menlo,monospace', + }, + }; + const result = await enrichBlock(block); + expect(result.innerHTML).toContain('font-family:Menlo,monospace'); + // No appended stack, and monospace is not doubled. + expect(result.innerHTML).not.toContain('Menlo,monospace,ui-monospace'); + expect(result.innerHTML).not.toContain('monospace,monospace'); + }); + + /** + * A value that is itself a generic family (`ui-monospace`) already provides a + * monospace fallback, so it is left unchanged. + */ + it('leaves a bare generic-family fontFamily unchanged', async () => { + const block: BlockDef = { + name: 'kevinbatdorf/code-block-pro', + attributes: { + code: 'const a = 1;', + language: 'javascript', + fontFamily: 'ui-monospace', + }, + }; + const result = await enrichBlock(block); + expect(result.innerHTML).toContain('font-family:ui-monospace'); + expect(result.innerHTML).not.toContain('ui-monospace,ui-monospace'); + expect(result.innerHTML).not.toContain('ui-monospace,SFMono-Regular'); + }); + it('includes copy-textarea when copyButton is enabled', async () => { const block: BlockDef = { name: 'kevinbatdorf/code-block-pro', diff --git a/src/enrichers.ts b/src/enrichers.ts index 500beea..1c42a33 100644 --- a/src/enrichers.ts +++ b/src/enrichers.ts @@ -250,6 +250,24 @@ function escapeAttr(value: string): string { .replace(/'/g, '''); } +/** + * Guarantee a CSS generic-family fallback on a font-family value. + * + * A custom CBP font-name like `Code-Pro-JetBrains-Mono` is not a loaded + * webfont, so `font-family:Code-Pro-JetBrains-Mono` alone makes browsers fall + * back to the default serif. The real CBP editor bakes a full monospace stack; + * mirror it here. If the value already contains a generic family keyword + * (`monospace`, `ui-monospace`, `sans-serif`, `serif`, `system-ui`, `cursive`, + * `fantasy`) it already has a usable fallback and is returned unchanged. The + * word-boundary match also matches inside `ui-monospace` and a value already + * ending in `monospace`, so re-runs never double-append. + */ +function ensureMonospaceFallback(fontFamily: string): string { + const hasGenericFamily = /\b(?:monospace|ui-monospace|sans-serif|serif|system-ui|cursive|fantasy)\b/i.test(fontFamily); + if (hasGenericFamily) return fontFamily; + return `${fontFamily},ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace`; +} + registerBlockEnricher('kevinbatdorf/code-block-pro', async (block) => { const attrs = block.attributes ?? {}; const code = attrs.code as string | undefined; @@ -329,7 +347,7 @@ registerBlockEnricher('kevinbatdorf/code-block-pro', async (block) => { // `foo" onclick="…`). The encoder collapses all five // attribute-significant characters to entities. const styleParts: string[] = []; - if (typeof attrs.fontFamily === 'string') styleParts.push(`font-family:${escapeAttr(attrs.fontFamily)}`); + if (typeof attrs.fontFamily === 'string') styleParts.push(`font-family:${escapeAttr(ensureMonospaceFallback(attrs.fontFamily))}`); if (typeof attrs.fontSize === 'string') styleParts.push(`font-size:${escapeAttr(attrs.fontSize)}`); if (typeof attrs.lineHeight === 'string') styleParts.push(`line-height:${escapeAttr(attrs.lineHeight)}`); if (typeof attrs.bgColor === 'string') styleParts.push(`background-color:${escapeAttr(attrs.bgColor)}`); From b25761f71a0139a4ce9ee7552f01bfa360f8a784 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 24 Jul 2026 08:53:02 -0400 Subject: [PATCH 2/6] fix(normalizers): repair stranded core/list values markup on write Inserting a core/list with its
  • items in the deprecated `values` attribute and an empty
      wrapper (no core/list-item children) saved a block that renders an empty list on modern WordPress. Add a write-path normalizer that bakes the items into the wrapper (reconciling ordered vs unordered), modeled on the core/image normalizer and running at the non-bypassable gk/block-mcp/block/normalize chokepoint. Idempotent, with guards that leave correct and genuinely-deprecated lists byte-identical. Claude-Session: https://claude.ai/code/session_017AMC5bVpEM9G3WtXbXtmwj --- .../class-core-list-normalizer.php | 220 +++++++++++++++++ .../tests/Block/BlockNormalizerTest.php | 221 ++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 wordpress-plugin/gk-block-mcp/includes/block-normalizers/class-core-list-normalizer.php diff --git a/wordpress-plugin/gk-block-mcp/includes/block-normalizers/class-core-list-normalizer.php b/wordpress-plugin/gk-block-mcp/includes/block-normalizers/class-core-list-normalizer.php new file mode 100644 index 0000000..3fc6940 --- /dev/null +++ b/wordpress-plugin/gk-block-mcp/includes/block-normalizers/class-core-list-normalizer.php @@ -0,0 +1,220 @@ +` items only in the deprecated `values` + * attribute, its wrapper (`
        `/`
          `) is empty, and it has no core/list-item + * innerBlocks. Modern core/list renders from core/list-item child blocks, so + * this markup renders an EMPTY list on the front end. + * + * No valid or deprecated core/list serialization produces this: a real + * values-based deprecation carries the `
        1. ` items INSIDE the wrapper in + * innerHTML, and a modern one has core/list-item children and no `values`. The + * repair bakes the `values` HTML into the wrapper, which both populates the + * front end and matches core/list's values-based deprecation so Gutenberg + * migrates it cleanly on the next edit. The `values` attribute is left in place + * for that reason. + * + * @package GravityKit\BlockMCP\Block_Normalizers + */ + +namespace GravityKit\BlockMCP\Block_Normalizers; + +defined( 'ABSPATH' ) || exit; + +/** + * Normalizer for core/list blocks. + * + * @since TODO + */ +class Core_List_Normalizer { + + /** + * Block name this normalizer targets. + */ + const BLOCK_NAME = 'core/list'; + + /** + * Register the filter hook. + * + * Called once at plugin init by the normalizer loader. + * + * @since TODO + * + * @return void + */ + public static function init() { + add_filter( 'gk/block-mcp/block/normalize', array( __CLASS__, 'normalize' ), 10, 2 ); + } + + /** + * Normalize a core/list block. + * + * Returns the block unchanged for any other block name, any block with + * core/list-item innerBlocks, any block without a non-empty `values` + * attribute, and any block whose wrapper already holds an `
        2. `. Otherwise + * it bakes the `values` HTML into the wrapper, reconciling the wrapper tag + * with the `ordered` attribute, and keeps the block a leaf. + * + * @since TODO + * + * @param array $block Block in WP-internal shape. + * @param string $block_name Block name being normalized. + * + * @return array + */ + public static function normalize( $block, $block_name ) { + if ( self::BLOCK_NAME !== $block_name || ! is_array( $block ) ) { + return $block; + } + + // Guard: a block with child blocks is a modern, valid list. + if ( ! empty( $block['innerBlocks'] ) ) { + return $block; + } + + $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); + $values = isset( $attrs['values'] ) && is_string( $attrs['values'] ) ? $attrs['values'] : ''; + + // Guard: nothing stranded in `values`, nothing to bake. + if ( '' === $values ) { + return $block; + } + + $html = isset( $block['innerHTML'] ) ? (string) $block['innerHTML'] : ''; + + // Guard: a wrapper that already holds an
        3. is a real (deprecated or + // current) serialization, not the empty-wrapper bug. This also makes a + // second normalize pass a byte-for-byte no-op. + if ( self::contains_li( $html ) ) { + return $block; + } + + $is_ordered = ! empty( $attrs['ordered'] ); + $html = self::bake_values_into_wrapper( $html, $values, $is_ordered, $attrs ); + + $block['innerHTML'] = $html; + if ( empty( $block['innerBlocks'] ) ) { + $block['innerContent'] = array( $html ); + } + + return $block; + } + + /** + * Whether an HTML fragment contains an `
        4. ` tag. + * + * @param string $html HTML fragment. + * + * @return bool + */ + private static function contains_li( $html ) { + if ( '' === $html ) { + return false; + } + $processor = new \WP_HTML_Tag_Processor( $html ); + while ( $processor->next_tag() ) { + if ( 'LI' === $processor->get_tag() ) { + return true; + } + } + return false; + } + + /** + * Bake the `values` HTML into the list wrapper. + * + * Reconciles the wrapper tag with the `ordered` attribute (emitting an + * `
            ` carrying type/start/reversed when ordered, else a `
              `), + * preserves the wp-block-list class and any existing wrapper attributes, and + * splices the `values` fragment between the wrapper's open and close tags. A + * missing wrapper is built from scratch. + * + * @param string $html The wrapper innerHTML (empty wrapper, or none). + * @param string $values The `values` HTML holding the
            • items. + * @param bool $is_ordered Whether the block's `ordered` attribute is truthy. + * @param array $attrs The block attributes. + * + * @return string + */ + private static function bake_values_into_wrapper( $html, $values, $is_ordered, $attrs ) { + $desired_tag = $is_ordered ? 'ol' : 'ul'; + $wrapper = self::prepare_wrapper( $html, $desired_tag, $is_ordered, $attrs ); + + // Splice the values in just before the wrapper's closing tag. The + // wrapper is empty at this point (guarded above), so there is exactly + // one closing tag; any nested sublist inside `values` therefore lands + // inside the wrapper rather than confusing the match. + $close = ''; + $pos = strripos( $wrapper, $close ); + if ( false === $pos ) { + return '<' . $desired_tag . ' class="wp-block-list">' . $values . $close; + } + + return substr( $wrapper, 0, $pos ) . $values . substr( $wrapper, $pos ); + } + + /** + * Produce the empty, correctly-tagged wrapper for the list. + * + * Locates the existing `
                `/`
                  ` (or builds one when absent), ensures + * the wp-block-list class, applies the ordered HTML attributes, and renames + * the tag to match `$desired_tag`. WP_HTML_Tag_Processor cannot rename a + * tag, so the swap is a bounded regex on the open tag plus a splice on the + * matching close tag. + * + * @param string $html The wrapper innerHTML (empty wrapper, or none). + * @param string $desired_tag Lowercase target tag (`ol` or `ul`). + * @param bool $is_ordered Whether the block's `ordered` attribute is truthy. + * @param array $attrs The block attributes. + * + * @return string + */ + private static function prepare_wrapper( $html, $desired_tag, $is_ordered, $attrs ) { + $processor = new \WP_HTML_Tag_Processor( $html ); + $wrapper_tag = null; + while ( $processor->next_tag() ) { + $tag = $processor->get_tag(); + if ( 'UL' === $tag || 'OL' === $tag ) { + $wrapper_tag = strtolower( $tag ); + break; + } + } + + if ( null === $wrapper_tag ) { + $html = '<' . $desired_tag . '>'; + $wrapper_tag = $desired_tag; + $processor = new \WP_HTML_Tag_Processor( $html ); + $processor->next_tag(); + } + + $processor->add_class( 'wp-block-list' ); + if ( $is_ordered ) { + $has_type = isset( $attrs['type'] ) && is_string( $attrs['type'] ) && '' !== $attrs['type']; + if ( $has_type ) { + $processor->set_attribute( 'type', $attrs['type'] ); + } + if ( isset( $attrs['start'] ) && is_scalar( $attrs['start'] ) ) { + $processor->set_attribute( 'start', (string) $attrs['start'] ); + } + if ( ! empty( $attrs['reversed'] ) ) { + $processor->set_attribute( 'reversed', true ); + } + } + $html = $processor->get_updated_html(); + + if ( $wrapper_tag !== $desired_tag ) { + $html = preg_replace( '/<' . $wrapper_tag . '\b/i', '<' . $desired_tag, $html, 1 ); + $close_old = ''; + $close_pos = strripos( $html, $close_old ); + if ( false !== $close_pos ) { + $html = substr( $html, 0, $close_pos ) . '' . substr( $html, $close_pos + strlen( $close_old ) ); + } + } + + return $html; + } +} + +Core_List_Normalizer::init(); diff --git a/wordpress-plugin/gk-block-mcp/tests/Block/BlockNormalizerTest.php b/wordpress-plugin/gk-block-mcp/tests/Block/BlockNormalizerTest.php index e324435..32d8d91 100644 --- a/wordpress-plugin/gk-block-mcp/tests/Block/BlockNormalizerTest.php +++ b/wordpress-plugin/gk-block-mcp/tests/Block/BlockNormalizerTest.php @@ -330,6 +330,227 @@ public function test_engine_applies_any_registered_normalizer() { $this->assertSame( 'yes', $out[0]['attrs']['data-marked'] ); } + /** + * The `values` HTML used across the core/list tests — two
                1. items that + * live in the deprecated `values` block attribute, not inside the wrapper. + */ + private const INVALID_LIST_VALUES = '
                2. First
                3. Second
                4. '; + + /** + * The invalid core/list innerHTML: an empty
                    wrapper with no
                  • items + * and no core/list-item innerBlocks. The items are stranded in `values`. + */ + private const INVALID_LIST_HTML = '
                      '; + + /** + * Build a flat core/list block array in WP-internal shape. + * + * @param array $attrs Block attributes (the JSON-comment delimiter payload). + * @param string $html The list wrapper innerHTML. + * + * @return array + */ + private function list_block( array $attrs, string $html ): array { + return array( + 'blockName' => 'core/list', + 'attrs' => $attrs, + 'innerHTML' => $html, + 'innerContent' => array( $html ), + 'innerBlocks' => array(), + ); + } + + /** + * Locate the first core/list block in a parsed tree. + * + * @param array $blocks parse_blocks() output. + * + * @return array|null + */ + private function find_list( array $blocks ) { + foreach ( $blocks as $block ) { + if ( isset( $block['blockName'] ) && 'core/list' === $block['blockName'] ) { + return $block; + } + } + return null; + } + + /** + * Assert that the stored core/list at the given post has its `values` items + * baked into the wrapper. + * + * @param int $post_id Post to read. + */ + private function assert_stored_list_baked( int $post_id ): void { + $list = $this->find_list( $this->block_tree( $post_id ) ); + $this->assertNotNull( $list, 'a core/list block must be present in stored content' ); + $this->assertStringContainsString( '
                    • First
                    • ', $list['innerHTML'], 'the first values item must be inside the wrapper' ); + $this->assertStringContainsString( '
                    • Second
                    • ', $list['innerHTML'], 'the second values item must be inside the wrapper' ); + $this->assertStringNotContainsString( '
                        ', $list['innerHTML'], 'the wrapper must no longer be empty' ); + } + + /** + * The reported bug: a core/list whose `
                      • ` items live in the deprecated + * `values` attribute, with an EMPTY
                          wrapper and no core/list-item + * innerBlocks, renders an empty list on modern WordPress (which builds the + * list from core/list-item children, not `values`). + * + * Normalization must bake the `values` HTML into the wrapper so the front + * end serves a populated list and the stored form matches core/list's + * values-based deprecation (Gutenberg migrates it cleanly on next edit). + * This signature is unique to agent-authored markup: a real values-based + * deprecation carries the
                        • items INSIDE the wrapper. + */ + public function test_core_list_values_with_empty_wrapper_are_baked_in() { + $block = $this->list_block( + array( 'values' => self::INVALID_LIST_VALUES ), + self::INVALID_LIST_HTML + ); + + $out = Block_Normalizer::normalize_tree( array( $block ) ); + + $this->assertStringContainsString( '
                        • First
                        • ', $out[0]['innerHTML'], 'first item must be baked into the wrapper' ); + $this->assertStringContainsString( '
                        • Second
                        • ', $out[0]['innerHTML'], 'second item must be baked into the wrapper' ); + $this->assertStringContainsString( 'wp-block-list', $out[0]['innerHTML'], 'the wp-block-list class must be preserved' ); + $this->assertSame( $out[0]['innerHTML'], $out[0]['innerContent'][0], 'innerContent must track the repaired innerHTML' ); + $this->assertSame( array(), $out[0]['innerBlocks'], 'the block must stay a leaf: no child blocks added' ); + } + + /** + * insert_blocks funnels through save_blocks(), the single write chokepoint. + * An invalid core/list inserted there must be baked before it is persisted. + */ + public function test_insert_blocks_normalizes_invalid_list() { + $post_id = $this->make_block_post(); + + $result = $this->crud->insert_blocks( + $post_id, + null, + array( + array( + 'name' => 'core/list', + 'attributes' => array( 'values' => self::INVALID_LIST_VALUES ), + 'innerHTML' => self::INVALID_LIST_HTML, + ), + ) + ); + + $this->assertNotWPError( $result ); + $this->assert_stored_list_baked( $post_id ); + } + + /** + * create_post is the sibling write funnel (Post_Manager serializes blocks + * directly, bypassing save_blocks). It must bake an invalid list too. + */ + public function test_create_post_normalizes_invalid_list() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + $pm = new \GravityKit\BlockMCP\Post_Manager( $this->crud ); + + $result = $pm->create_post( + array( + 'title' => 'List Normalization', + 'blocks' => array( + array( + 'name' => 'core/list', + 'attributes' => array( 'values' => self::INVALID_LIST_VALUES ), + 'innerHTML' => self::INVALID_LIST_HTML, + ), + ), + ) + ); + + $this->assertIsArray( $result ); + $this->assert_stored_list_baked( (int) $result['id'] ); + } + + /** + * Normalization is idempotent: once the items are inside the wrapper the + * "wrapper already contains an
                        • " guard makes a second pass a + * byte-for-byte no-op. Without it the second pass would re-append `values`. + */ + public function test_normalize_list_is_idempotent() { + $block = $this->list_block( + array( 'values' => self::INVALID_LIST_VALUES ), + self::INVALID_LIST_HTML + ); + + $once = Block_Normalizer::normalize_tree( array( $block ) ); + $twice = Block_Normalizer::normalize_tree( $once ); + + $this->assertSame( $once, $twice ); + } + + /** + * No false repair: a correctly-authored modern core/list stores its items as + * core/list-item innerBlocks (and carries no `values`). The non-empty + * innerBlocks guard must leave it byte-identical. + */ + public function test_valid_list_with_list_item_children_is_not_modified() { + $child_html = '
                        • Item
                        • '; + $list_item = array( + 'blockName' => 'core/list-item', + 'attrs' => array(), + 'innerHTML' => $child_html, + 'innerContent' => array( $child_html ), + 'innerBlocks' => array(), + ); + $block = array( + 'blockName' => 'core/list', + 'attrs' => array(), + 'innerHTML' => '
                            ', + 'innerContent' => array( '
                              ', null, '
                            ' ), + 'innerBlocks' => array( $list_item ), + ); + + $out = Block_Normalizer::normalize_tree( array( $block ) ); + + $this->assertSame( array( $block ), $out, 'a modern list with child blocks must not be touched' ); + } + + /** + * A nested sublist and an inline link carried inside `values` must survive + * the bake: the whole values fragment (its links and any nested + *
                              /
                                ) lands inside the wrapper verbatim. + */ + public function test_core_list_nested_sublist_and_inline_link_survive() { + $values = '
                              1. Link
                              2. Parent
                                • Child
                              3. '; + $block = $this->list_block( + array( 'values' => $values ), + self::INVALID_LIST_HTML + ); + + $out = Block_Normalizer::normalize_tree( array( $block ) ); + + $this->assertStringContainsString( 'Link', $out[0]['innerHTML'], 'inline link must survive' ); + $this->assertStringContainsString( '
                              4. Child
                              5. ', $out[0]['innerHTML'], 'nested sublist item must survive' ); + $this->assertStringContainsString( $values, $out[0]['innerHTML'], 'the values fragment must be baked in verbatim' ); + } + + /** + * The wrapper tag is reconciled with the `ordered` attribute. A block that + * (wrongly) supplies a
                                  while `ordered` is true must be emitted as an + *
                                    carrying the ordered HTML attributes (here `start`). + */ + public function test_ordered_list_with_ul_supplied_emits_ol() { + $block = $this->list_block( + array( + 'values' => self::INVALID_LIST_VALUES, + 'ordered' => true, + 'start' => 3, + ), + self::INVALID_LIST_HTML + ); + + $out = Block_Normalizer::normalize_tree( array( $block ) ); + + $this->assertStringContainsString( '' ); + $this->assertStringNotContainsString( ' wrapper must be reconciled away' ); + $this->assertStringContainsString( 'start="3"', $out[0]['innerHTML'], 'the ordered start attribute must be carried onto the
                                      ' ); + $this->assertStringContainsString( '
                                    1. First
                                    2. ', $out[0]['innerHTML'], 'the values items must be baked into the
                                        ' ); + } + /** * update_block's `saved` snapshot must echo what actually landed on disk. * From 6ebc515fa29c88b37f5dd94e7cae48bbbd774fa6 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Fri, 24 Jul 2026 09:36:40 -0400 Subject: [PATCH 3/6] build: regenerate MCP server bundle for the font fix The embedded dist bundle (assets/mcp-server/index.cjs) is committed and verified against a fresh build in CI. Rebuild it so it includes the ensureMonospaceFallback change from the enrichers fix. Claude-Session: https://claude.ai/code/session_017AMC5bVpEM9G3WtXbXtmwj --- wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 1936e72..1b1e14d 100755 --- a/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs +++ b/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs @@ -52349,6 +52349,11 @@ function inferLanguage(code) { function escapeAttr(value) { return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } +function ensureMonospaceFallback(fontFamily) { + const hasGenericFamily = /\b(?:monospace|ui-monospace|sans-serif|serif|system-ui|cursive|fantasy)\b/i.test(fontFamily); + if (hasGenericFamily) return fontFamily; + return `${fontFamily},ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace`; +} registerBlockEnricher("kevinbatdorf/code-block-pro", async (block) => { const attrs = block.attributes ?? {}; const code = attrs.code; @@ -52381,7 +52386,7 @@ registerBlockEnricher("kevinbatdorf/code-block-pro", async (block) => { ); } else { const styleParts = []; - if (typeof attrs.fontFamily === "string") styleParts.push(`font-family:${escapeAttr(attrs.fontFamily)}`); + if (typeof attrs.fontFamily === "string") styleParts.push(`font-family:${escapeAttr(ensureMonospaceFallback(attrs.fontFamily))}`); if (typeof attrs.fontSize === "string") styleParts.push(`font-size:${escapeAttr(attrs.fontSize)}`); if (typeof attrs.lineHeight === "string") styleParts.push(`line-height:${escapeAttr(attrs.lineHeight)}`); if (typeof attrs.bgColor === "string") styleParts.push(`background-color:${escapeAttr(attrs.bgColor)}`); From 0e1bdf30d1990d89ba890430722cfb57d97fa9db Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 10 Aug 2026 12:09:50 -0400 Subject: [PATCH 4/6] Sync CBP wrapper font-family during in-place enrichment The code-block-pro enricher has two innerHTML paths. The fresh-wrapper path runs ensureMonospaceFallback() on the font stack; the in-place path rewrites only the
                                         and the copy ',
                                        +    };
                                        +  }
                                        +
                                        +  it('rewrites a stale wrapper font-family from the current attribute', async () => {
                                        +    const result = await enrichBlock(
                                        +      blockWithWrapper({ fontFamily: 'Menlo,monospace' }),
                                        +    );
                                        +    expect(result.innerHTML).toContain('font-family:Menlo,monospace;font-size:1rem');
                                        +    expect(result.innerHTML).not.toContain('font-family:Code-Pro-JetBrains-Mono;');
                                        +  });
                                        +
                                        +  /**
                                        +   * With no fontFamily attribute to go on, the value already in the markup is
                                        +   * still repaired — that bare name is exactly the serif-fallback bug.
                                        +   */
                                        +  it('adds a generic fallback to a bare family already in the wrapper', async () => {
                                        +    const result = await enrichBlock(blockWithWrapper());
                                        +    expect(result.innerHTML).toContain(
                                        +      'font-family:Code-Pro-JetBrains-Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace',
                                        +    );
                                        +  });
                                        +
                                        +  /**
                                        +   * CBP's own save() packs CSS custom properties into the same style attribute.
                                        +   * Rebuilding the attribute wholesale would drop them and take line numbers and
                                        +   * theme colours with it, so only the one declaration may be touched.
                                        +   */
                                        +  it('preserves other declarations and custom properties in the style attribute', async () => {
                                        +    const block: BlockDef = {
                                        +      name: 'kevinbatdorf/code-block-pro',
                                        +      attributes: { code: 'const a = 1;', language: 'javascript', fontFamily: 'Menlo,monospace' },
                                        +      innerHTML:
                                        +        '
                                        ' + + '
                                        stale
                                        ', + }; + const result = await enrichBlock(block); + expect(result.innerHTML).toContain('--cbp-line-number-color:#d8dee9ff'); + expect(result.innerHTML).toContain('--shiki-token-comment:#8899aa'); + expect(result.innerHTML).toContain('cbp-has-line-numbers'); + // The webfont-loading attribute tracks the same value. + expect(result.innerHTML).toContain('data-code-block-pro-font-family="Menlo,monospace"'); + }); + + /** + * A fontFamily-only edit reaches the enricher with identical codeHTML and + * language and so hits the early bail-out. It must still produce an update, + * or the attribute saves while the rendered markup keeps the old font. + */ + it('still updates when only fontFamily changed', async () => { + const first = await enrichBlock(blockWithWrapper({ fontFamily: 'Menlo,monospace' })); + + const restyled: BlockDef = { + name: 'kevinbatdorf/code-block-pro', + attributes: { ...first.attributes, fontFamily: 'Consolas,monospace' }, + innerHTML: first.innerHTML, + }; + const second = await enrichBlock(restyled); + + expect(second.innerHTML).toContain('font-family:Consolas,monospace'); + expect(second.innerHTML).not.toContain('font-family:Menlo,monospace'); + }); + + it('leaves markup untouched when the font already matches', async () => { + const first = await enrichBlock(blockWithWrapper({ fontFamily: 'Menlo,monospace' })); + + const unchanged: BlockDef = { + name: 'kevinbatdorf/code-block-pro', + attributes: { ...first.attributes }, + innerHTML: first.innerHTML, + }; + const second = await enrichBlock(unchanged); + expect(second).toBe(unchanged); + }); +}); diff --git a/src/enrichers.ts b/src/enrichers.ts index 1c42a33..6009aa3 100644 --- a/src/enrichers.ts +++ b/src/enrichers.ts @@ -268,6 +268,56 @@ function ensureMonospaceFallback(fontFamily: string): string { return `${fontFamily},ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace`; } +/** + * Hold the CBP wrapper's font-family to the block's attributes, with a generic + * family always present. + * + * The in-place branch below rewrites only the
                                         and the copy ` in the
                                           // source would otherwise close the element early and corrupt innerHTML.
                                        @@ -335,6 +391,7 @@ registerBlockEnricher('kevinbatdorf/code-block-pro', async (block) => {
                                               /(]*>)([\s\S]*?)(<\/textarea>)/,
                                               (_m, open, _old, close) => `${open}${encodedCode}${close}`,
                                             );
                                        +    updatedInnerHTML = syncWrapperFontFamily(updatedInnerHTML, attrs.fontFamily);
                                           } else {
                                             // Mirror CBP's save() inline style attribute. Without these the wrapper
                                             // falls back to theme defaults and the code uses the surrounding font /
                                        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 1b1e14d..9fed0ff 100755
                                        --- a/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs
                                        +++ b/wordpress-plugin/gk-block-mcp/assets/mcp-server/index.cjs
                                        @@ -52354,6 +52354,28 @@ function ensureMonospaceFallback(fontFamily) {
                                           if (hasGenericFamily) return fontFamily;
                                           return `${fontFamily},ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace`;
                                         }
                                        +function syncWrapperFontFamily(innerHTML, fontFamily) {
                                        +  const openTagPattern = /
                                        `font-family:${nextFont}`); + } else if (/\sstyle="/.test(tag)) { + tag = tag.replace(/\sstyle="/, () => ` style="font-family:${nextFont};`); + } else { + tag = tag.replace(/>$/, () => ` style="font-family:${nextFont}">`); + } + tag = tag.replace( + /data-code-block-pro-font-family="[^"]*"/, + () => `data-code-block-pro-font-family="${nextFont}"` + ); + return innerHTML.replace(openTagPattern, () => tag); +} registerBlockEnricher("kevinbatdorf/code-block-pro", async (block) => { const attrs = block.attributes ?? {}; const code = attrs.code; @@ -52369,10 +52391,12 @@ registerBlockEnricher("kevinbatdorf/code-block-pro", async (block) => { const codeHTML = await shikiHighlight(code, effectiveLang, themeName); const highestLineNumber = code.split("\n").length; const incomingInnerHTML = block.innerHTML ?? ""; + const updatedAttrs = { ...attrs, language: lang29, codeHTML, highestLineNumber }; if (codeHTML === attrs.codeHTML && lang29 === rawLang && incomingInnerHTML !== "") { - return null; + const syncedInnerHTML = syncWrapperFontFamily(incomingInnerHTML, attrs.fontFamily); + if (syncedInnerHTML === incomingInnerHTML) return null; + return { ...block, attributes: updatedAttrs, innerHTML: syncedInnerHTML }; } - const updatedAttrs = { ...attrs, language: lang29, codeHTML, highestLineNumber }; const encodedCode = code.replace(/&/g, "&").replace(//g, ">"); let updatedInnerHTML; if (incomingInnerHTML !== "") { @@ -52384,6 +52408,7 @@ registerBlockEnricher("kevinbatdorf/code-block-pro", async (block) => { /(]*>)([\s\S]*?)(<\/textarea>)/, (_m, open, _old, close) => `${open}${encodedCode}${close}` ); + updatedInnerHTML = syncWrapperFontFamily(updatedInnerHTML, attrs.fontFamily); } else { const styleParts = []; if (typeof attrs.fontFamily === "string") styleParts.push(`font-family:${escapeAttr(ensureMonospaceFallback(attrs.fontFamily))}`); diff --git a/wordpress-plugin/gk-block-mcp/readme.txt b/wordpress-plugin/gk-block-mcp/readme.txt index a64e543..489a85e 100644 --- a/wordpress-plugin/gk-block-mcp/readme.txt +++ b/wordpress-plugin/gk-block-mcp/readme.txt @@ -120,6 +120,13 @@ Visit Settings → Block MCP. Set the score for a namespace to less than 10 to m == Changelog == += develop = + +#### 🐛 Fixed + +* Changing a code block's font through the assistant updated the setting but left the displayed code unchanged. +* Existing code blocks whose font was saved without a fallback could display in the browser's default serif instead of a monospace face. + = 2.2.0 on July 23, 2026 = This release adds tools for browsing and safely editing your theme's Full Site Editing templates, creating reusable patterns, and listing block binding sources, along with richer block-type and pattern discovery. It also restores editing on sites behind a server firewall and keeps your saved settings from being overwritten. From 2b86cf474a523c768d55a061df5a0bded9a11c72 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 10 Aug 2026 13:10:59 -0400 Subject: [PATCH 5/6] fix: address review on font fallback and list normalization Generic font families are matched as whole comma-separated entries. A substring test read `Source Serif 4` and `custom-monospace-font` as already carrying a generic family and withheld the fallback stack those names most need. A font-family that is blank or carries CSS structure is now dropped rather than emitted. escapeAttr stops a value breaking out of the attribute, but the declaration is spliced among others, so an unescaped `;` or `{` appended CSS of the caller's choosing. The list normalizer sanitizes the markup it bakes. `values` is attribute data and normalization runs after the write path's innerHTML sanitization, so a `Second' ), + 'innerHTML' => self::INVALID_LIST_HTML, + ), + ) + ); + + $this->assertNotWPError( $result ); + + $stored = get_post( $post_id )->post_content; + $list = $this->find_list( $this->block_tree( $post_id ) ); + + $this->assertNotNull( $list, 'a core/list block must be present in stored content' ); + $this->assertStringContainsString( '
                                      1. First
                                      2. ', $list['innerHTML'], 'benign values items must still be baked in' ); + $this->assertStringNotContainsString( '' ), + self::INVALID_LIST_HTML + ); + + $out = Block_Normalizer::normalize_tree( array( $block ) ); + + $this->assertSame( self::INVALID_LIST_HTML, $out[0]['innerHTML'], 'a values fragment sanitized down to no
                                      3. must not be baked in' ); + $this->assertStringNotContainsString( 'alert(1)', $out[0]['innerHTML'], 'stripped script contents must not land in the wrapper' ); + } + + /** + * A closing tag carrying whitespace still receives the items. + * + * `
                                ` is a valid end tag, but a literal `
                            ` search misses it, so the + * bake fell through to the unclosed-wrapper fallback and appended the items + * AFTER the existing closing tag — outside the list entirely. + */ + public function test_closing_tag_with_whitespace_receives_the_items_inside_the_wrapper() { + $block = $this->list_block( + array( 'values' => self::INVALID_LIST_VALUES ), + '
                              ' + ); + + $out = Block_Normalizer::normalize_tree( array( $block ) ); + $html = $out[0]['innerHTML']; + + $item_pos = strpos( $html, '
                            • First
                            • ' ); + $close_pos = strpos( $html, 'assertNotFalse( $item_pos, 'the values items must be baked in' ); + $this->assertNotFalse( $close_pos, 'the wrapper must still be closed' ); + $this->assertLessThan( $close_pos, $item_pos, 'the items must sit INSIDE the wrapper, before its closing tag' ); + $this->assertSame( 1, substr_count( $html, '
                            ` had its opening tag renamed to + * `` search missed the close, leaving the + * mismatched pair `
                            `. + */ + public function test_tag_rename_rewrites_a_closing_tag_with_whitespace() { + $block = $this->list_block( + array( + 'values' => self::INVALID_LIST_VALUES, + 'ordered' => true, + ), + '
                              ' + ); + + $out = Block_Normalizer::normalize_tree( array( $block ) ); + $html = $out[0]['innerHTML']; + + $this->assertStringContainsString( '
                            ', $html, 'the closing tag must be rewritten to match the renamed wrapper' ); + $this->assertStringNotContainsString( 'list_block( + array( 'values' => self::INVALID_LIST_VALUES ), + '
                              ' + ); + + $out = Block_Normalizer::normalize_tree( array( $block ) ); + + $this->assertStringNotContainsString( 'onclick', $out[0]['innerHTML'], 'an event handler on the wrapper must not survive normalization' ); + $this->assertStringContainsString( '
                            • First
                            • ', $out[0]['innerHTML'], 'the values items must still be baked in' ); + } + /** * update_block's `saved` snapshot must echo what actually landed on disk. *