Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference-guides/core-blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions packages/block-library/src/code/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
"selector": "code",
"__unstablePreserveWhiteSpace": true,
"role": "content"
},
"language": {
"type": "string",
"default": ""
}
},
"supports": {
Expand Down
92 changes: 74 additions & 18 deletions packages/block-library/src/code/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,24 +22,70 @@ export default function CodeEdit( {
insertBlocksAfter,
mergeBlocks,
} ) {
const { content, language } = attributes;
const blockProps = useBlockProps();
return (
<pre { ...blockProps }>
<RichText
tagName="code"
identifier="content"
value={ attributes.content }
onChange={ ( content ) => setAttributes( { content } ) }
onRemove={ onRemove }
onMerge={ mergeBlocks }
placeholder={ __( 'Write code…' ) }
aria-label={ __( 'Code' ) }
preserveWhiteSpace
__unstablePastePlainText
__unstableOnSplitAtDoubleLineEnd={ () =>
insertBlocksAfter( createBlock( getDefaultBlockName() ) )
}
/>
</pre>
<>
<InspectorControls>
<PanelBody title={ __( 'Code Language' ) }>
<TextControl
label={ __( 'Language' ) }
value={ language || '' }
onChange={ ( value ) => {
const nextLanguage = sanitizeLanguage( value );
if ( nextLanguage === ( language || '' ) ) {
return;
}
setAttributes( {
language: nextLanguage,
} );
} }
help={ __(
'Enter a language slug, e.g., php, javascript, python.'
) }
autoComplete="off"
__next40pxDefaultSize
/>
</PanelBody>
</InspectorControls>
<pre { ...blockProps }>
<RichText
tagName="code"
identifier="content"
value={ content }
onChange={ ( newContent ) => {
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() )
)
}
/>
</pre>
</>
);
}
10 changes: 7 additions & 3 deletions packages/block-library/src/code/save.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I just saw this example is in the HTML spec... nice!


return (
<pre { ...useBlockProps.save() }>
<RichText.Content
tagName="code"
className={ codeClassName }
// To do: `escape` encodes characters in shortcodes and URLs to
// prevent embedding in PHP. Ideally checks for the code block,
// or pre/code tags, should be made on the PHP side?
value={ escape(
typeof attributes.content === 'string'
? attributes.content
: attributes.content.toHTMLString( {
typeof content === 'string'
? content
: content.toHTMLString( {
preserveWhiteSpace: true,
} )
) }
Expand Down
29 changes: 29 additions & 0 deletions packages/block-library/src/code/transforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { create, toHTMLString } from '@wordpress/rich-text';
* Internal dependencies
*/
import { getTransformedAttributes } from '../utils/get-transformed-attributes';
import { parseFencedCode } from './utils';

const transforms = {
from: [
Expand All @@ -21,6 +22,15 @@ const transforms = {
blocks: [ 'core/paragraph' ],
transform: ( attributes ) => {
const { content } = attributes;
const parsedFencedCode = parseFencedCode( content, {
allowEndOfString: true,
} );
if ( parsedFencedCode ) {
return createBlock( 'core/code', {
...getTransformedAttributes( attributes, 'core/code' ),
...parsedFencedCode,
} );
}
Comment thread
carolinan marked this conversation as resolved.
Resolved
return createBlock( 'core/code', {
...attributes,
...getTransformedAttributes( attributes, 'core/code' ),
Expand Down Expand Up @@ -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': {},
},
Expand Down
61 changes: 61 additions & 0 deletions packages/block-library/src/code/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <br>).
*
* @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/;
Comment thread
carolinan marked this conversation as resolved.
Resolved
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]*$/, '' );
Comment thread
carolinan marked this conversation as resolved.
Resolved

return {
content: parsedContent,
language: parsedLanguage,
};
}
Comment thread
carolinan marked this conversation as resolved.
Resolved

/**
* Escapes ampersands, shortcodes, and links.
*
Expand Down
3 changes: 2 additions & 1 deletion test/integration/fixtures/blocks/core__code.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"name": "core/code",
"isValid": true,
"attributes": {
"content": "export default function MyButton() {\n\treturn &lt;Button&gt;Click Me!&lt;/Button&gt;;\n}"
"content": "export default function MyButton() {\n\treturn &lt;Button&gt;Click Me!&lt;/Button&gt;;\n}",
"language": ""
},
"innerBlocks": []
}
Expand Down
Loading