diff --git a/src/__tests__/unit/enrichers/cbp-enricher.test.ts b/src/__tests__/unit/enrichers/cbp-enricher.test.ts index 2025dc1..723e4f3 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', @@ -219,11 +281,11 @@ describe('enrichBlock — Code Block Pro', () => { attributes: { code: 'const a = 1;', language: 'javascript', - fontFamily: 'Arial" onerror="alert(1)', + fontFamily: 'Arial" onerror="alert', }, }; const result = await enrichBlock(block); - expect(result.innerHTML).not.toContain('onerror="alert(1)'); + expect(result.innerHTML).not.toContain('onerror="alert'); expect(result.innerHTML).toContain('"'); }); @@ -476,3 +538,165 @@ describe('registerBlockEnricher', () => { expect(result).toBe(block); }); }); + +// ── Wrapper font-family sync (existing innerHTML) ───────────────────────────── + +/** + * The in-place branch rewrites 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;');
+  });
+
+  /**
+   * A generic family counts only as a whole comma-separated entry. Matching it
+   * as a substring reads a custom name that merely contains one — `Source
+   * Serif 4`, `custom-monospace-font` — as already-safe and withholds the
+   * fallback stack those names most need.
+   */
+  it('adds the fallback to custom names that merely contain a generic family', async () => {
+    const serif = await enrichBlock(blockWithWrapper({ fontFamily: 'Source Serif 4' }));
+    expect(serif.innerHTML).toContain(
+      'font-family:Source Serif 4,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace',
+    );
+
+    const mono = await enrichBlock(blockWithWrapper({ fontFamily: 'custom-monospace-font' }));
+    expect(mono.innerHTML).toContain(
+      'font-family:custom-monospace-font,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace',
+    );
+  });
+
+  /**
+   * A real generic entry is still recognized, including when quoted or padded,
+   * so the helper stays idempotent across re-runs.
+   */
+  it('leaves a stack that already ends in a generic family unchanged', async () => {
+    const result = await enrichBlock(
+      blockWithWrapper({ fontFamily: '"Fira Code", monospace' }),
+    );
+    expect(result.innerHTML).toContain('font-family:"Fira Code", monospace;');
+    expect(result.innerHTML).not.toContain('SFMono-Regular');
+  });
+
+  /**
+   * A font-family is spliced in among other declarations, so a value carrying
+   * CSS structure would append declarations of the caller's choosing. Blank and
+   * structurally-invalid values are dropped rather than emitted.
+   */
+  it('drops a blank or CSS-bearing font-family instead of emitting it', async () => {
+    const blank = await enrichBlock(blockWithWrapper({ fontFamily: '   ' }));
+    expect(blank.innerHTML).not.toContain('font-family:;');
+    expect(blank.innerHTML).not.toContain('font-family: ;');
+
+    const hostile = await enrichBlock(
+      blockWithWrapper({ fontFamily: 'Menlo;background:url(x)' }),
+    );
+    expect(hostile.innerHTML).not.toContain('background:url(x)');
+  });
+
+  /**
+   * 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. + * + * CBP's front-end script picks the webfont to load from the wrapper's + * data attribute, so that has to track the same value as the style + * declaration — a wrapper carrying one must not be left on the old font. + */ + it('still updates when only fontFamily changed', async () => { + const seeded: BlockDef = { + name: 'kevinbatdorf/code-block-pro', + attributes: { code: 'const a = 1;', language: 'javascript', fontFamily: 'Menlo,monospace' }, + innerHTML: + '
' + + '
stale
', + }; + const first = await enrichBlock(seeded); + + 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'); + expect(second.innerHTML).toContain('data-code-block-pro-font-family="Consolas,monospace"'); + expect(second.innerHTML).not.toContain('data-code-block-pro-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 500beea..7512216 100644 --- a/src/enrichers.ts +++ b/src/enrichers.ts @@ -250,6 +250,104 @@ 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. A value that already ends in a generic family has a usable + * fallback and is returned unchanged, which also makes re-runs idempotent. + * + * The generic family must be a whole comma-separated entry. A substring test + * reads `Source Serif 4` or `custom-monospace-font` as generic and skips the + * fallback those names most need. + */ +const GENERIC_FONT_FAMILIES = new Set([ + 'monospace', + 'ui-monospace', + 'sans-serif', + 'serif', + 'system-ui', + 'cursive', + 'fantasy', +]); + +function ensureMonospaceFallback(fontFamily: string): string { + const entries = fontFamily + .split(',') + .map((entry) => entry.trim().replace(/^["']|["']$/g, '').toLowerCase()); + const hasGenericFamily = entries.some((entry) => GENERIC_FONT_FAMILIES.has(entry)); + if (hasGenericFamily) return fontFamily; + return `${fontFamily},ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace`; +} + +/** + * Whether a caller-supplied font-family can be interpolated into a style + * attribute. + * + * `escapeAttr` stops a value from breaking out of the attribute, but a + * font-family is spliced in among other declarations, so an unescaped `;`, `{` + * or `}` would still append CSS of the caller's choosing. None of those, nor + * the parens a `url()` needs, appear in a real font-family value. + */ +function isUsableFontFamily(fontFamily: unknown): fontFamily is string { + if (typeof fontFamily !== 'string') return false; + const trimmed = fontFamily.trim(); + if (trimmed === '') return false; + return !/[;{}<>()\\]/.test(trimmed); +} + +/** + * 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.
@@ -317,6 +421,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 /
@@ -329,7 +434,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 (isUsableFontFamily(attrs.fontFamily)) 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)}`);
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..3fd18f8 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,49 @@ function inferLanguage(code) {
 function escapeAttr(value) {
   return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
 }
+var GENERIC_FONT_FAMILIES = /* @__PURE__ */ new Set([
+  "monospace",
+  "ui-monospace",
+  "sans-serif",
+  "serif",
+  "system-ui",
+  "cursive",
+  "fantasy"
+]);
+function ensureMonospaceFallback(fontFamily) {
+  const entries = fontFamily.split(",").map((entry) => entry.trim().replace(/^["']|["']$/g, "").toLowerCase());
+  const hasGenericFamily = entries.some((entry) => GENERIC_FONT_FAMILIES.has(entry));
+  if (hasGenericFamily) return fontFamily;
+  return `${fontFamily},ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace`;
+}
+function isUsableFontFamily(fontFamily) {
+  if (typeof fontFamily !== "string") return false;
+  const trimmed = fontFamily.trim();
+  if (trimmed === "") return false;
+  return !/[;{}<>()\\]/.test(trimmed);
+}
+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; @@ -52364,10 +52407,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 !== "") { @@ -52379,9 +52424,10 @@ 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(attrs.fontFamily)}`); + if (isUsableFontFamily(attrs.fontFamily)) 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)}`); 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..7266ae4 --- /dev/null +++ b/wordpress-plugin/gk-block-mcp/includes/block-normalizers/class-core-list-normalizer.php @@ -0,0 +1,275 @@ +` 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 kept for that + * reason, sanitized to the same markup that was baked in. + * + * @package GravityKit\BlockMCP\Block_Normalizers + */ + +namespace GravityKit\BlockMCP\Block_Normalizers; + +use GravityKit\BlockMCP\Block_Writer; + +defined( 'ABSPATH' ) || exit; + +/** + * Normalizer for core/list blocks. + * + * @since 2.2.1 + */ +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 2.2.1 + * + * @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 2.2.1 + * + * @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. + $has_li = self::contains_li( $html ); + if ( $has_li ) { + return $block; + } + + // `values` is attribute data and normalization runs after the write + // path's innerHTML sanitization, so baking it in raw would put whatever + // the attribute holds into post_content as live markup. The sanitized + // value is written back to the attribute as well: Gutenberg matches the + // values-based deprecation by regenerating markup from `values`, so the + // two must agree or the repaired block reads as invalid again. + $values = Block_Writer::sanitize_inner_html( $values ); + + // Guard: sanitization can reduce the fragment to markup carrying no + //
    4. at all. Splicing that into the wrapper would emit a list whose + // children are not list items. + if ( ! self::contains_li( $values ) ) { + return $block; + } + + $attrs['values'] = $values; + $block['attrs'] = $attrs; + + $is_ordered = ! empty( $attrs['ordered'] ); + $html = self::bake_values_into_wrapper( $html, $values, $is_ordered, $attrs ); + + // The wrapper is assembled here from the incoming innerHTML plus + // attribute data, so the composed result is sanitized as a whole before + // it becomes post_content. Sanitization is idempotent, so the already + // sanitized `values` fragment survives byte-for-byte and keeps agreeing + // with the attribute written above. + $html = Block_Writer::sanitize_inner_html( $html ); + + // The block is a leaf here — a block with innerBlocks returned above — + // so one innerContent chunk keeps the null-placeholder invariant. + $block['innerHTML'] = $html; + $block['innerContent'] = array( $html ); + + return $block; + } + + /** + * Locate the last closing tag for a tag name. + * + * An end tag may carry whitespace before its `>` (`
` is valid), so a + * literal `` search misses it — the caller then splices content outside + * the wrapper, or leaves a mismatched closing tag behind after a rename. + * + * @param string $html HTML fragment to search. + * @param string $tag Lowercase tag name. + * + * @return array|null `offset` and `length` of the last match, null when absent. + */ + private static function find_last_closing_tag( $html, $tag ) { + $pattern = '##i'; + $found = preg_match_all( $pattern, $html, $matches, PREG_OFFSET_CAPTURE ); + if ( ! $found ) { + return null; + } + $last = end( $matches[0] ); + return array( + 'offset' => $last[1], + 'length' => strlen( $last[0] ), + ); + } + + /** + * Whether an HTML fragment contains an `
  • ` 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 = self::find_last_closing_tag( $wrapper, $desired_tag ); + if ( null === $close ) { + // An unclosed wrapper still carries the class and the ordered + // attributes prepare_wrapper() applied, so close it rather than + // rebuilding a bare opening tag and dropping them. + return $wrapper . $values . ''; + } + + return substr( $wrapper, 0, $close['offset'] ) . $values . substr( $wrapper, $close['offset'] ); + } + + /** + * 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'] ); + } + $has_start = isset( $attrs['start'] ) && is_scalar( $attrs['start'] ); + if ( $has_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 = self::find_last_closing_tag( $html, $wrapper_tag ); + if ( null !== $close ) { + $html = substr( $html, 0, $close['offset'] ) . '' . substr( $html, $close['offset'] + $close['length'] ); + } + } + + return $html; + } +} + +Core_List_Normalizer::init(); diff --git a/wordpress-plugin/gk-block-mcp/readme.txt b/wordpress-plugin/gk-block-mcp/readme.txt index 603ff9c..51d3c88 100644 --- a/wordpress-plugin/gk-block-mcp/readme.txt +++ b/wordpress-plugin/gk-block-mcp/readme.txt @@ -125,6 +125,9 @@ Visit Settings → Block MCP. Set the score for a namespace to less than 10 to m #### 🐛 Fixed * A damaged plugin install no longer breaks the WordPress admin. If one of the plugin's files goes missing or is emptied, every admin page returned a critical error and WordPress emailed a recovery link. Now the rest of the admin keeps loading, and a notice explains that part of Block MCP could not be loaded and that reinstalling usually fixes it. +* 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. +* A list written by the assistant could show up empty on the site and as invalid in the editor, even though its items were saved. = 2.2.0 on July 23, 2026 = diff --git a/wordpress-plugin/gk-block-mcp/tests/Block/BlockNormalizerTest.php b/wordpress-plugin/gk-block-mcp/tests/Block/BlockNormalizerTest.php index e324435..6e7cca3 100644 --- a/wordpress-plugin/gk-block-mcp/tests/Block/BlockNormalizerTest.php +++ b/wordpress-plugin/gk-block-mcp/tests/Block/BlockNormalizerTest.php @@ -330,6 +330,400 @@ 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, + 'type' => 'A', + 'reversed' => true, + ), + 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( 'type="A"', $out[0]['innerHTML'], 'the ordered type attribute must be carried onto the
                                  ' ); + $this->assertStringContainsString( 'reversed', $out[0]['innerHTML'], 'the reversed attribute must be carried onto the
                                    ' ); + $this->assertStringContainsString( '
                                  1. First
                                  2. ', $out[0]['innerHTML'], 'the values items must be baked into the
                                      ' ); + } + + /** + * Markup baked out of `values` must be sanitized. + * + * `values` is attribute data, and normalization runs after the write path's + * innerHTML sanitization chokepoint (Block_Writer::sanitize_inner_html), so + * baking it in raw published whatever the attribute carried as live markup: + * 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. *