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' } } - /> -
++ > ); } 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 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' } } + /> +
{ 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": [] }