From 20980ed2e4690a41f90775108b91af1d273aa245 Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Wed, 18 Mar 2026 17:00:21 +0100 Subject: [PATCH 01/11] Add language support to code block Introduce a language attribute for core/code blocks and expose a Code Language inspector control (TextControl) to set it. Parse fenced code blocks from pasted/edited content and normalize language slugs (sanitizeLanguage, parseFencedCode, toHTMLStr). Persist the language as a language- class on the element in save output. Update block transforms to extract language from and to create blocks from fenced code, and update docs to document the new attribute. --- docs/reference-guides/core-blocks.md | 2 +- packages/block-library/src/code/block.json | 4 + packages/block-library/src/code/edit.js | 92 +++++++++++++++---- packages/block-library/src/code/save.js | 10 +- packages/block-library/src/code/transforms.js | 29 ++++++ packages/block-library/src/code/utils.js | 61 ++++++++++++ 6 files changed, 176 insertions(+), 22 deletions(-) diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 02ac8095cb59e3..eb43ab75811946 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -138,7 +138,7 @@ Display code snippets that respect your spacing and tabs. ([Source](https://gith - **Name:** core/code - **Category:** text - **Supports:** align (wide), anchor, color (background, gradients, text), interactivity (clientNavigation), spacing (margin, padding), typography (fontSize, lineHeight) -- **Attributes:** content +- **Attributes:** content, language ## Column diff --git a/packages/block-library/src/code/block.json b/packages/block-library/src/code/block.json index 547c370f2bc502..70b699ea9f733b 100644 --- a/packages/block-library/src/code/block.json +++ b/packages/block-library/src/code/block.json @@ -13,6 +13,10 @@ "selector": "code", "__unstablePreserveWhiteSpace": true, "role": "content" + }, + "language": { + "type": "string", + "default": "" } }, "supports": { diff --git a/packages/block-library/src/code/edit.js b/packages/block-library/src/code/edit.js index a3dbedeaf23352..1d96e53186845d 100644 --- a/packages/block-library/src/code/edit.js +++ b/packages/block-library/src/code/edit.js @@ -2,8 +2,18 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { RichText, useBlockProps } from '@wordpress/block-editor'; +import { + RichText, + useBlockProps, + InspectorControls, +} from '@wordpress/block-editor'; import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; +import { TextControl, PanelBody } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { sanitizeLanguage, toHTMLStr, parseFencedCode } from './utils'; export default function CodeEdit( { attributes, @@ -12,24 +22,70 @@ export default function CodeEdit( { insertBlocksAfter, mergeBlocks, } ) { + const { content, language } = attributes; const blockProps = useBlockProps(); return ( -
-			 setAttributes( { content } ) }
-				onRemove={ onRemove }
-				onMerge={ mergeBlocks }
-				placeholder={ __( 'Write code…' ) }
-				aria-label={ __( 'Code' ) }
-				preserveWhiteSpace
-				__unstablePastePlainText
-				__unstableOnSplitAtDoubleLineEnd={ () =>
-					insertBlocksAfter( createBlock( getDefaultBlockName() ) )
-				}
-			/>
-		
+ <> + + + { + const nextLanguage = sanitizeLanguage( value ); + if ( nextLanguage === ( language || '' ) ) { + return; + } + setAttributes( { + language: nextLanguage, + } ); + } } + help={ __( + 'Enter a language slug, e.g., php, javascript, python.' + ) } + autoComplete="off" + __next40pxDefaultSize + /> + + +
+				 {
+						const parsedFencedCode = parseFencedCode( newContent );
+
+						if ( parsedFencedCode ) {
+							// Compare against the string form of current content
+							// since parsedContent is always a plain string.
+							if (
+								parsedFencedCode.content ===
+									toHTMLStr( content ) &&
+								parsedFencedCode.language === ( language || '' )
+							) {
+								return;
+							}
+
+							setAttributes( parsedFencedCode );
+							return;
+						}
+
+						setAttributes( { content: newContent } );
+					} }
+					onRemove={ onRemove }
+					onMerge={ mergeBlocks }
+					placeholder={ __( 'Write code…' ) }
+					aria-label={ __( 'Code' ) }
+					preserveWhiteSpace
+					__unstablePastePlainText
+					__unstableOnSplitAtDoubleLineEnd={ () =>
+						insertBlocksAfter(
+							createBlock( getDefaultBlockName() )
+						)
+					}
+				/>
+			
+ ); } diff --git a/packages/block-library/src/code/save.js b/packages/block-library/src/code/save.js index 977fb074c2a53e..87c9628b2ab610 100644 --- a/packages/block-library/src/code/save.js +++ b/packages/block-library/src/code/save.js @@ -9,17 +9,21 @@ import { RichText, useBlockProps } from '@wordpress/block-editor'; import { escape } from './utils'; export default function save( { attributes } ) { + const { content, language } = attributes; + const codeClassName = language ? `language-${ language }` : undefined; + return (
 			 {
 				const { content } = attributes;
+				const parsedFencedCode = parseFencedCode( content, {
+					allowEndOfString: true,
+				} );
+				if ( parsedFencedCode ) {
+					return createBlock( 'core/code', {
+						...getTransformedAttributes( attributes, 'core/code' ),
+						...parsedFencedCode,
+					} );
+				}
 				return createBlock( 'core/code', {
 					...attributes,
 					...getTransformedAttributes( attributes, 'core/code' ),
@@ -48,10 +58,29 @@ const transforms = {
 				node.nodeName === 'PRE' &&
 				node.children.length === 1 &&
 				node.firstChild.nodeName === 'CODE',
+			transform: ( node ) => {
+				const codeElement = node.firstChild;
+				const className = codeElement.getAttribute( 'class' ) || '';
+				const classNames = className.split( /\s+/ );
+				const languageClass = classNames.find( ( token ) =>
+					token.startsWith( 'language-' )
+				);
+				const language = languageClass
+					? languageClass.slice( 'language-'.length )
+					: '';
+
+				return createBlock( 'core/code', {
+					content: toHTMLString( {
+						value: create( { html: codeElement.innerHTML } ),
+					} ),
+					language,
+				} );
+			},
 			schema: {
 				pre: {
 					children: {
 						code: {
+							attributes: [ 'class' ],
 							children: {
 								'#text': {},
 							},
diff --git a/packages/block-library/src/code/utils.js b/packages/block-library/src/code/utils.js
index 0beabaa8ee1d9d..870d6cdcdf7f41 100644
--- a/packages/block-library/src/code/utils.js
+++ b/packages/block-library/src/code/utils.js
@@ -3,6 +3,67 @@
  */
 import { pipe } from '@wordpress/compose';
 
+/**
+ * Strips a leading `language-` prefix from a language identifier and removes
+ * any whitespace, normalising it to a plain class-token-safe string.
+ *
+ * @param {string} value Raw language input.
+ * @return {string} Sanitized language slug.
+ */
+export function sanitizeLanguage( value ) {
+	return value
+		.trim()
+		.replace( /^language-/i, '' )
+		.replace( /\s+/g, '' );
+}
+
+/**
+ * Returns the HTML string representation of a RichTextData object or plain
+ * string, preserving whitespace (newlines are kept as-is, not as 
). + * + * @param {string|Object} value A plain string or RichTextData object. + * @return {string} HTML string. + */ +export function toHTMLStr( value ) { + if ( typeof value === 'string' ) { + return value; + } + return value?.toHTMLString?.( { preserveWhiteSpace: true } ) ?? ''; +} + +/** + * Parses a Markdown fenced code opening (3 backticks + language) from a + * content value. + * By default, this only matches a complete opening line ending in a newline. + * + * @param {string|Object} value RichTextData or plain string content. + * @param {Object} options Parsing options. + * @param {boolean} options.allowEndOfString Whether to accept end-of-string + * instead of a trailing newline. + * @return {Object|null} Parsed result with `content` and `language`, or null. + */ +export function parseFencedCode( value, { allowEndOfString = false } = {} ) { + const htmlString = toHTMLStr( value ); + const openingFenceRegExp = allowEndOfString + ? /^```([^\s`]+)[ \t]*(?:\r?\n|$)/ + : /^```([^\s`]+)[ \t]*\r?\n/; + const openingFenceMatch = htmlString.match( openingFenceRegExp ); + if ( ! openingFenceMatch ) { + return null; + } + + const parsedLanguage = sanitizeLanguage( openingFenceMatch[ 1 ] ); + let parsedContent = htmlString.slice( openingFenceMatch[ 0 ].length ); + + // Strip a trailing closing fence if present. + parsedContent = parsedContent.replace( /\r?\n```[ \t]*$/, '' ); + + return { + content: parsedContent, + language: parsedLanguage, + }; +} + /** * Escapes ampersands, shortcodes, and links. * From 42f8a515cebf739954fb21cc86b0cb08452f952a Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Thu, 19 Mar 2026 04:19:46 +0100 Subject: [PATCH 02/11] Update the integration test fixture to include the language attribute --- test/integration/fixtures/blocks/core__code.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/integration/fixtures/blocks/core__code.json b/test/integration/fixtures/blocks/core__code.json index c99536d3736489..b5ebaa9ae9d9d6 100644 --- a/test/integration/fixtures/blocks/core__code.json +++ b/test/integration/fixtures/blocks/core__code.json @@ -3,7 +3,8 @@ "name": "core/code", "isValid": true, "attributes": { - "content": "export default function MyButton() {\n\treturn <Button>Click Me!</Button>;\n}" + "content": "export default function MyButton() {\n\treturn <Button>Click Me!</Button>;\n}", + "language": "" }, "innerBlocks": [] } From 55aed6fa3236e3dccefd5054eaa18b6c6c60c6ee Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Thu, 19 Mar 2026 10:05:18 +0100 Subject: [PATCH 03/11] Potential fix for pull request finding Allow optional spaces after the backticks Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/block-library/src/code/utils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/code/utils.js b/packages/block-library/src/code/utils.js index 870d6cdcdf7f41..19248e3d394a6e 100644 --- a/packages/block-library/src/code/utils.js +++ b/packages/block-library/src/code/utils.js @@ -45,8 +45,8 @@ export function toHTMLStr( value ) { export function parseFencedCode( value, { allowEndOfString = false } = {} ) { const htmlString = toHTMLStr( value ); const openingFenceRegExp = allowEndOfString - ? /^```([^\s`]+)[ \t]*(?:\r?\n|$)/ - : /^```([^\s`]+)[ \t]*\r?\n/; + ? /^```[ \t]*([^\s`]+)[ \t]*(?:\r?\n|$)/ + : /^```[ \t]*([^\s`]+)[ \t]*\r?\n/; const openingFenceMatch = htmlString.match( openingFenceRegExp ); if ( ! openingFenceMatch ) { return null; From 2bcc47f37bf540b58f7da586257ba4ac393808d8 Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Thu, 19 Mar 2026 10:12:12 +0100 Subject: [PATCH 04/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/block-library/src/code/utils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/code/utils.js b/packages/block-library/src/code/utils.js index 19248e3d394a6e..184eb75c50d6ea 100644 --- a/packages/block-library/src/code/utils.js +++ b/packages/block-library/src/code/utils.js @@ -55,8 +55,8 @@ export function parseFencedCode( value, { allowEndOfString = false } = {} ) { const parsedLanguage = sanitizeLanguage( openingFenceMatch[ 1 ] ); let parsedContent = htmlString.slice( openingFenceMatch[ 0 ].length ); - // Strip a trailing closing fence if present. - parsedContent = parsedContent.replace( /\r?\n```[ \t]*$/, '' ); + // Strip a trailing closing fence if present, allowing an optional final newline. + parsedContent = parsedContent.replace( /\r?\n```[ \t]*(?:\r?\n[ \t]*)?$/, '' ); return { content: parsedContent, From b7b28d0ff7e8e833fc6e3ced46e52733c40529a2 Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Thu, 19 Mar 2026 10:19:12 +0100 Subject: [PATCH 05/11] Fix linting issue --- packages/block-library/src/code/utils.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/block-library/src/code/utils.js b/packages/block-library/src/code/utils.js index 184eb75c50d6ea..6ba6c20288ce5e 100644 --- a/packages/block-library/src/code/utils.js +++ b/packages/block-library/src/code/utils.js @@ -56,7 +56,10 @@ export function parseFencedCode( value, { allowEndOfString = false } = {} ) { let parsedContent = htmlString.slice( openingFenceMatch[ 0 ].length ); // Strip a trailing closing fence if present, allowing an optional final newline. - parsedContent = parsedContent.replace( /\r?\n```[ \t]*(?:\r?\n[ \t]*)?$/, '' ); + parsedContent = parsedContent.replace( + /\r?\n```[ \t]*(?:\r?\n[ \t]*)?$/, + '' + ); return { content: parsedContent, From 67b6e66d2b44c2f7c141c9855dafbe3d5b4dfaad Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Thu, 19 Mar 2026 10:25:08 +0100 Subject: [PATCH 06/11] Transforming fenced code pass the attributes Previously, some attributes like border styles did not work when transforming to a code block. --- packages/block-library/src/code/transforms.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/block-library/src/code/transforms.js b/packages/block-library/src/code/transforms.js index 212e53bf364372..c9d43646cb14c7 100644 --- a/packages/block-library/src/code/transforms.js +++ b/packages/block-library/src/code/transforms.js @@ -27,6 +27,7 @@ const transforms = { } ); if ( parsedFencedCode ) { return createBlock( 'core/code', { + ...attributes, ...getTransformedAttributes( attributes, 'core/code' ), ...parsedFencedCode, } ); From 6cfdf2f9f0fed27d13c22454a6c250fc158afb9e Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Thu, 19 Mar 2026 10:33:09 +0100 Subject: [PATCH 07/11] Add test for the new utils Courtesy of Copilot --- packages/block-library/src/code/test/utils.js | 160 +++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/packages/block-library/src/code/test/utils.js b/packages/block-library/src/code/test/utils.js index d3e9de573cf77f..32457c5e459186 100644 --- a/packages/block-library/src/code/test/utils.js +++ b/packages/block-library/src/code/test/utils.js @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { escape } from '../utils'; +import { escape, sanitizeLanguage, toHTMLStr, parseFencedCode } from '../utils'; describe( 'core/code', () => { describe( 'escape()', () => { @@ -20,4 +20,162 @@ describe( 'core/code', () => { expect( text ).toBe( 'Text https://example.com/test/' ); } ); } ); + + describe( 'sanitizeLanguage()', () => { + it( 'should strip leading and trailing whitespace', () => { + expect( sanitizeLanguage( ' php ' ) ).toBe( 'php' ); + } ); + + it( 'should remove internal whitespace', () => { + expect( sanitizeLanguage( 'type script' ) ).toBe( 'typescript' ); + } ); + + it( 'should strip a leading language- prefix', () => { + expect( sanitizeLanguage( 'language-php' ) ).toBe( 'php' ); + } ); + + it( 'should strip a leading language- prefix case-insensitively', () => { + expect( sanitizeLanguage( 'LANGUAGE-JavaScript' ) ).toBe( + 'JavaScript' + ); + } ); + + it( 'should strip whitespace and language- prefix together', () => { + expect( sanitizeLanguage( ' language-css ' ) ).toBe( 'css' ); + } ); + + it( 'should return an empty string for an empty input', () => { + expect( sanitizeLanguage( '' ) ).toBe( '' ); + } ); + } ); + + describe( 'toHTMLStr()', () => { + it( 'should return a plain string unchanged', () => { + expect( toHTMLStr( 'hello world' ) ).toBe( 'hello world' ); + } ); + + it( 'should return an empty string for null', () => { + expect( toHTMLStr( null ) ).toBe( '' ); + } ); + + it( 'should return an empty string for undefined', () => { + expect( toHTMLStr( undefined ) ).toBe( '' ); + } ); + + it( 'should call toHTMLString with preserveWhiteSpace on a RichTextData-like object', () => { + const mockRichText = { + toHTMLString: jest + .fn() + .mockReturnValue( 'bold' ), + }; + const result = toHTMLStr( mockRichText ); + expect( mockRichText.toHTMLString ).toHaveBeenCalledWith( { + preserveWhiteSpace: true, + } ); + expect( result ).toBe( 'bold' ); + } ); + } ); + + describe( 'parseFencedCode()', () => { + it( 'should return null for non-fenced input', () => { + expect( parseFencedCode( 'just a plain paragraph' ) ).toBeNull(); + } ); + + it( 'should return null when the opening fence has no language', () => { + expect( parseFencedCode( '```\nsome code\n```' ) ).toBeNull(); + } ); + + it( 'should parse a basic fenced code block', () => { + const parsed = parseFencedCode( '```js\nconsole.log( 1 );\n```' ); + + expect( parsed ).toEqual( { + content: 'console.log( 1 );', + language: 'js', + } ); + } ); + + it( 'should parse an opening fence with spaces before the language', () => { + const parsed = parseFencedCode( '``` php\n { + const parsed = parseFencedCode( + '```\t\tjs\nconsole.log( 1 );\n```' + ); + + expect( parsed ).toEqual( { + content: 'console.log( 1 );', + language: 'js', + } ); + } ); + + it( 'should strip a language- prefix from the info string', () => { + const parsed = parseFencedCode( + '```language-php\n { + const parsed = parseFencedCode( '```js\nconsole.log( 1 );\n```' ); + + expect( parsed ).toEqual( { + content: 'console.log( 1 );', + language: 'js', + } ); + } ); + + it( 'should strip a closing fence followed by a final newline', () => { + const parsed = parseFencedCode( '```js\nconsole.log( 1 );\n```\n' ); + + expect( parsed ).toEqual( { + content: 'console.log( 1 );', + language: 'js', + } ); + } ); + + it( 'should preserve content when no closing fence is present', () => { + const parsed = parseFencedCode( '```js\nconsole.log( 1 );' ); + + expect( parsed ).toEqual( { + content: 'console.log( 1 );', + language: 'js', + } ); + } ); + + it( 'should handle CRLF line endings', () => { + const parsed = parseFencedCode( + '```js\r\nconsole.log( 1 );\r\n```' + ); + + expect( parsed ).toEqual( { + content: 'console.log( 1 );', + language: 'js', + } ); + } ); + + it( 'should accept end-of-string as the fence terminator when allowEndOfString is true', () => { + const parsed = parseFencedCode( '```js', { + allowEndOfString: true, + } ); + + expect( parsed ).toEqual( { + content: '', + language: 'js', + } ); + } ); + + it( 'should return null without a trailing newline when allowEndOfString is false', () => { + expect( parseFencedCode( '```js' ) ).toBeNull(); + } ); + } ); } ); From 2d8da238ccd6f63e28bc7d3e6997fb42abe9f1a4 Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Fri, 20 Mar 2026 08:59:37 +0100 Subject: [PATCH 08/11] Update transforms.js --- packages/block-library/src/code/transforms.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/block-library/src/code/transforms.js b/packages/block-library/src/code/transforms.js index c9d43646cb14c7..762e3806b3dc9f 100644 --- a/packages/block-library/src/code/transforms.js +++ b/packages/block-library/src/code/transforms.js @@ -13,9 +13,15 @@ import { parseFencedCode } from './utils'; const transforms = { from: [ { - type: 'input', - regExp: /^```$/, - transform: () => createBlock( 'core/code' ), + type: 'enter', + regExp: /^```(?:[ \t]*[^\s`]+)?[ \t]*$/, + transform: ( { content } ) => { + const parsedFencedCode = parseFencedCode( content, { + allowEndOfString: true, + } ); + + return createBlock( 'core/code', parsedFencedCode || {} ); + }, }, { type: 'block', From e1e208359f3437b25adf3b2aba3f2d1513599b57 Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Fri, 20 Mar 2026 09:22:03 +0100 Subject: [PATCH 09/11] try ensuring that three backticks and enter creates a code block --- packages/block-library/src/code/transforms.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/code/transforms.js b/packages/block-library/src/code/transforms.js index 762e3806b3dc9f..28bb2e909c12d6 100644 --- a/packages/block-library/src/code/transforms.js +++ b/packages/block-library/src/code/transforms.js @@ -14,13 +14,17 @@ const transforms = { from: [ { type: 'enter', - regExp: /^```(?:[ \t]*[^\s`]+)?[ \t]*$/, + regExp: /^```(?:[ \t]*[^\s`]+)?(?:[ \t]*(?:\r?\n|))?[ \t]*$/, transform: ( { content } ) => { const parsedFencedCode = parseFencedCode( content, { allowEndOfString: true, } ); - return createBlock( 'core/code', parsedFencedCode || {} ); + if ( parsedFencedCode ) { + return createBlock( 'core/code', parsedFencedCode ); + } + + return createBlock( 'core/code' ); }, }, { From 229dc3a551b97c007e80c7ddfcfc5d24086958ae Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Mon, 4 May 2026 07:16:12 +0200 Subject: [PATCH 10/11] Try to solve merge conflict --- packages/block-library/src/code/edit.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/block-library/src/code/edit.js b/packages/block-library/src/code/edit.js index 1d96e53186845d..71ef6f452e2e73 100644 --- a/packages/block-library/src/code/edit.js +++ b/packages/block-library/src/code/edit.js @@ -84,6 +84,7 @@ export default function CodeEdit( { createBlock( getDefaultBlockName() ) ) } + style={ { whiteSpace: 'break-spaces' } } />
From 5adfdd1f72688275cfee539cd15e3e2f7e3c316e Mon Sep 17 00:00:00 2001 From: Carolina Nymark Date: Fri, 10 Jul 2026 08:07:52 +0200 Subject: [PATCH 11/11] Remove 40px default size from TextControl --- packages/block-library/src/code/edit.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/block-library/src/code/edit.js b/packages/block-library/src/code/edit.js index 71ef6f452e2e73..2ab2e9994f6a21 100644 --- a/packages/block-library/src/code/edit.js +++ b/packages/block-library/src/code/edit.js @@ -44,7 +44,6 @@ export default function CodeEdit( { 'Enter a language slug, e.g., php, javascript, python.' ) } autoComplete="off" - __next40pxDefaultSize />