diff --git a/docs/reference-guides/core-blocks/README.md b/docs/reference-guides/core-blocks/README.md index e4e5251886f6c2..4c447c9382c952 100644 --- a/docs/reference-guides/core-blocks/README.md +++ b/docs/reference-guides/core-blocks/README.md @@ -148,7 +148,7 @@ Display code snippets that respect your spacing and tabs. - **Name:** [core/code](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-text/core-block-code/) - **Category:** [text](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/core-blocks-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/README.md b/packages/block-library/src/code/README.md index 5286082bc7ffab..03a8795875ee36 100644 --- a/packages/block-library/src/code/README.md +++ b/packages/block-library/src/code/README.md @@ -15,6 +15,7 @@ _Defined via the [`attributes`](https://developer.wordpress.org/block-editor/ref | Attribute | [Type](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#type-validation) | [Default](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#default-value) | Description | |-----------|------|---------|-------------| | `content` | `rich-text` | — | [Source](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#value-source): `rich-text`. [Selector](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#value-source): `code`. [Role](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#role): `content` | +| `language` | `string` | `""` | — | ## Supports 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 5e3c6db3bf0db5..2ab2e9994f6a21 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,25 +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() ) )
-				}
-				style={ { whiteSpace: 'break-spaces' } }
-			/>
-		
+ <> + + + { + const nextLanguage = sanitizeLanguage( value ); + if ( nextLanguage === ( language || '' ) ) { + return; + } + setAttributes( { + language: nextLanguage, + } ); + } } + help={ __( + 'Enter a language slug, e.g., php, javascript, python.' + ) } + autoComplete="off" + /> + + +
+				 {
+						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() )
+						)
+					}
+					style={ { whiteSpace: 'break-spaces' } }
+				/>
+			
+ ); } 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 (
 			 {
 	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();
+		} );
+	} );
 } );
diff --git a/packages/block-library/src/code/transforms.js b/packages/block-library/src/code/transforms.js
index 041232210b02e7..9e4dd554e9665d 100644
--- a/packages/block-library/src/code/transforms.js
+++ b/packages/block-library/src/code/transforms.js
@@ -8,19 +8,40 @@ import { create, toHTMLString } from '@wordpress/rich-text';
  * Internal dependencies
  */
 import { getTransformedAttributes } from '../utils/get-transformed-attributes';
+import { parseFencedCode } from './utils';
 
 const transforms = {
 	from: [
 		{
-			type: 'input',
-			regExp: /^```$/,
-			transform: () => createBlock( 'core/code' ),
+			type: 'enter',
+			regExp: /^```(?:[ \t]*[^\s`]+)?(?:[ \t]*(?:\r?\n|))?[ \t]*$/,
+			transform: ( { content } ) => {
+				const parsedFencedCode = parseFencedCode( content, {
+					allowEndOfString: true,
+				} );
+
+				if ( parsedFencedCode ) {
+					return createBlock( 'core/code', parsedFencedCode );
+				}
+
+				return createBlock( 'core/code' );
+			},
 		},
 		{
 			type: 'block',
 			blocks: [ 'core/paragraph' ],
 			transform: ( attributes ) => {
 				const { content } = attributes;
+				const parsedFencedCode = parseFencedCode( content, {
+					allowEndOfString: true,
+				} );
+				if ( parsedFencedCode ) {
+					return createBlock( 'core/code', {
+						...attributes,
+						...getTransformedAttributes( attributes, 'core/code' ),
+						...parsedFencedCode,
+					} );
+				}
 				return createBlock( 'core/code', {
 					...attributes,
 					...getTransformedAttributes( attributes, 'core/code' ),
@@ -50,10 +71,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..6ba6c20288ce5e 100644
--- a/packages/block-library/src/code/utils.js
+++ b/packages/block-library/src/code/utils.js
@@ -3,6 +3,70 @@
  */
 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 + ? /^```[ \t]*([^\s`]+)[ \t]*(?:\r?\n|$)/ + : /^```[ \t]*([^\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, allowing an optional final newline. + parsedContent = parsedContent.replace( + /\r?\n```[ \t]*(?:\r?\n[ \t]*)?$/, + '' + ); + + return { + content: parsedContent, + language: parsedLanguage, + }; +} + /** * Escapes ampersands, shortcodes, and links. * 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": [] }