From f63b197e9e0cacbbb2235d4b9452b9015295a06c Mon Sep 17 00:00:00 2001 From: Miguel Fonseca Date: Tue, 19 Nov 2019 11:04:45 +0000 Subject: [PATCH] Transforms: Shortcode: Support `isMatch` predicate (#18459) * Transforms: Shortcode: Support `isMatch` predicate * Docs: Transforms: Extend `isMatch` support to shortcodes * Tests: Raw Handling: Cover Shortcode's `isMatch` * Tests: Raw Handling: Error if missing fixtures --- .../block-api/block-registration.md | 18 ++++ .../api/raw-handling/shortcode-converter.js | 20 ++++- test/integration/blocks-raw-handling.test.js | 40 ++++++++- .../fixtures/shortcode-matching-in.html | 3 + .../fixtures/shortcode-matching-out.html | 7 ++ test/integration/shortcode-converter.test.js | 89 +++++++++++++++++++ 6 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 test/integration/fixtures/shortcode-matching-in.html create mode 100644 test/integration/fixtures/shortcode-matching-out.html diff --git a/docs/designers-developers/developers/block-api/block-registration.md b/docs/designers-developers/developers/block-api/block-registration.md index 217bbd27608ac..8faef83ce92e3 100644 --- a/docs/designers-developers/developers/block-api/block-registration.md +++ b/docs/designers-developers/developers/block-api/block-registration.md @@ -439,6 +439,24 @@ transforms: { ``` {% end %} +In the case of shortcode transforms, `isMatch` receives shortcode attributes per the [Shortcode API](https://codex.wordpress.org/Shortcode_API): + +{% codetabs %} +{% ES5 %} +```js +isMatch: function( attributes ) { + return attributes.named.id === 'my-id'; +}, +``` +{% ESNext %} +```js +isMatch( { named: { id } } ) { + return id === 'my-id'; +}, +``` +{% end %} + + To control the priority with which a transform is applied, define a `priority` numeric property on your transform object, where a lower value will take precedence over higher values. This behaves much like a [WordPress hook](https://codex.wordpress.org/Plugin_API#Hook_to_WordPress). Like hooks, the default priority is `10` when not otherwise set. A file can be dropped into the editor and converted into a block with a matching transform. diff --git a/packages/blocks/src/api/raw-handling/shortcode-converter.js b/packages/blocks/src/api/raw-handling/shortcode-converter.js index 10f1159c92cb0..807b90e279f79 100644 --- a/packages/blocks/src/api/raw-handling/shortcode-converter.js +++ b/packages/blocks/src/api/raw-handling/shortcode-converter.js @@ -15,11 +15,12 @@ import { createBlock, getBlockTransforms, findTransform } from '../factory'; import { getBlockType } from '../registration'; import { getBlockAttributes } from '../parser'; -function segmentHTMLToShortcodeBlock( HTML, lastIndex = 0 ) { +function segmentHTMLToShortcodeBlock( HTML, lastIndex = 0, excludedBlockNames = [] ) { // Get all matches. const transformsFrom = getBlockTransforms( 'from' ); const transformation = findTransform( transformsFrom, ( transform ) => ( + excludedBlockNames.indexOf( transform.blockName ) === -1 && transform.type === 'shortcode' && some( castArray( transform.tag ), ( tag ) => regexp( tag ).test( HTML ) ) ) ); @@ -32,6 +33,7 @@ function segmentHTMLToShortcodeBlock( HTML, lastIndex = 0 ) { const transformTag = find( transformTags, ( tag ) => regexp( tag ).test( HTML ) ); let match; + const previousIndex = lastIndex; if ( ( match = next( transformTag, HTML, lastIndex ) ) ) { const beforeHTML = HTML.substr( 0, match.index ); @@ -49,6 +51,22 @@ function segmentHTMLToShortcodeBlock( HTML, lastIndex = 0 ) { return segmentHTMLToShortcodeBlock( HTML, lastIndex ); } + // If a transformation's `isMatch` predicate fails for the inbound + // shortcode, try again by excluding the current block type. + // + // This is the only call to `segmentHTMLToShortcodeBlock` that should + // ever carry over `excludedBlockNames`. Other calls in the module + // should skip that argument as a way to reset the exclusion state, so + // that one `isMatch` fail in an HTML fragment doesn't prevent any + // valid matches in subsequent fragments. + if ( transformation.isMatch && ! transformation.isMatch( match.shortcode.attrs ) ) { + return segmentHTMLToShortcodeBlock( + HTML, + previousIndex, + [ ...excludedBlockNames, transformation.blockName ], + ); + } + const attributes = mapValues( pickBy( transformation.attributes, ( schema ) => schema.shortcode ), // Passing all of `match` as second argument is intentionally broad diff --git a/test/integration/blocks-raw-handling.test.js b/test/integration/blocks-raw-handling.test.js index 228c6588ef96b..db4a5a09ac914 100644 --- a/test/integration/blocks-raw-handling.test.js +++ b/test/integration/blocks-raw-handling.test.js @@ -11,6 +11,7 @@ import { getBlockContent, pasteHandler, rawHandler, + registerBlockType, serialize, } from '@wordpress/blocks'; import { registerCoreBlocks } from '@wordpress/block-library'; @@ -24,6 +25,38 @@ describe( 'Blocks raw handling', () => { // Load all hooks that modify blocks require( '../../packages/editor/src/hooks' ); registerCoreBlocks(); + registerBlockType( 'test/gallery', { + title: 'Test Gallery', + category: 'common', + attributes: { + ids: { + type: 'array', + default: [], + }, + }, + transforms: { + from: [ + { + type: 'shortcode', + tag: 'gallery', + isMatch( { named: { ids } } ) { + return ids.indexOf( 42 ) > -1; + }, + attributes: { + ids: { + type: 'array', + shortcode: ( { named: { ids } } ) => + ids.split( ',' ).map( ( id ) => ( + parseInt( id, 10 ) + ) ), + }, + }, + priority: 9, + }, + ], + }, + save: () => null, + } ); } ); it( 'should filter inline content', () => { @@ -248,12 +281,17 @@ describe( 'Blocks raw handling', () => { 'markdown', 'wordpress', 'gutenberg', - 'caption-shortcode', + 'shortcode-matching', ].forEach( ( type ) => { it( type, () => { const HTML = readFile( path.join( __dirname, `fixtures/${ type }-in.html` ) ); const plainText = readFile( path.join( __dirname, `fixtures/${ type }-in.txt` ) ); const output = readFile( path.join( __dirname, `fixtures/${ type }-out.html` ) ); + + if ( ! ( HTML || plainText ) || ! output ) { + throw new Error( `Expected fixtures for type ${ type }` ); + } + const converted = pasteHandler( { HTML, plainText, canUserUseUnfilteredHTML: true } ); const serialized = typeof converted === 'string' ? converted : serialize( converted ); diff --git a/test/integration/fixtures/shortcode-matching-in.html b/test/integration/fixtures/shortcode-matching-in.html new file mode 100644 index 0000000000000..f5b46fa16abc9 --- /dev/null +++ b/test/integration/fixtures/shortcode-matching-in.html @@ -0,0 +1,3 @@ +

[gallery ids="40,41,42"]

+

[gallery ids="1000"]

+

[gallery ids="42"]

diff --git a/test/integration/fixtures/shortcode-matching-out.html b/test/integration/fixtures/shortcode-matching-out.html new file mode 100644 index 0000000000000..02599dea66294 --- /dev/null +++ b/test/integration/fixtures/shortcode-matching-out.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/integration/shortcode-converter.test.js b/test/integration/shortcode-converter.test.js index 1b0ce7c30947d..dc59ee49d2fb5 100644 --- a/test/integration/shortcode-converter.test.js +++ b/test/integration/shortcode-converter.test.js @@ -40,6 +40,60 @@ describe( 'segmentHTMLToShortcodeBlock', () => { }, save: () => null, } ); + registerBlockType( 'test/broccoli', { + title: 'Test Broccoli', + category: 'common', + attributes: { + id: { + type: 'number', + }, + }, + transforms: { + from: [ + { + type: 'shortcode', + tag: [ 'my-broccoli' ], + attributes: { + id: { + type: 'number', + shortcode: ( { named: { id } } ) => parseInt( id, 10 ), + }, + }, + isMatch( { named: { id } } ) { + return id < 1000; + }, + }, + ], + }, + save: () => null, + } ); + registerBlockType( 'test/fallback-broccoli', { + title: 'Test Fallback Broccoli', + category: 'common', + attributes: { + id: { + type: 'number', + }, + }, + transforms: { + from: [ + { + type: 'shortcode', + tag: [ 'my-broccoli' ], + attributes: { + id: { + type: 'number', + shortcode: ( { named: { id } } ) => parseInt( id, 10 ), + }, + }, + isMatch( { named: { id } } ) { + return id > 1000; + }, + }, + ], + }, + save: () => null, + } ); } ); it( 'should convert a standalone shortcode between two paragraphs', () => { @@ -64,6 +118,41 @@ describe( 'segmentHTMLToShortcodeBlock', () => {

Bar

` ); } ); + it( 'should convert a shortcode to a block type with a passing `isMatch`', () => { + const original = `

[my-broccoli id="42"]

`; + + const transformed = segmentHTMLToShortcodeBlock( original, 0 ); + const expectedBlock = createBlock( 'test/broccoli', { id: 42 } ); + expectedBlock.clientId = transformed[ 1 ].clientId; + expect( transformed[ 1 ] ).toEqual( expectedBlock ); + } ); + + it( 'should not convert a shortcode to a block type with a failing `isMatch`', () => { + const original = `

[my-broccoli id="1000"]

`; + + const transformed = segmentHTMLToShortcodeBlock( original, 0 ); + const expectedBlock = createBlock( 'core/shortcode' ); + expectedBlock.clientId = transformed[ 1 ].clientId; + expect( transformed[ 1 ] ).toEqual( expectedBlock ); + } ); + + it( 'should not blindly exclude a transform in subsequent shortcodes after a failed `isMatch`', () => { + const original = `

[my-broccoli id="1001"]

+

[my-broccoli id="42"]

+

[my-broccoli id="1000"]

`; + + const transformed = segmentHTMLToShortcodeBlock( original ); + const firstExpectedBlock = createBlock( 'test/fallback-broccoli', { id: 1001 } ); + firstExpectedBlock.clientId = transformed[ 1 ].clientId; + const secondExpectedBlock = createBlock( 'test/broccoli', { id: 42 } ); + secondExpectedBlock.clientId = transformed[ 3 ].clientId; + const thirdExpectedBlock = createBlock( 'core/shortcode' ); + thirdExpectedBlock.clientId = transformed[ 5 ].clientId; + expect( transformed[ 1 ] ).toEqual( firstExpectedBlock ); + expect( transformed[ 3 ] ).toEqual( secondExpectedBlock ); + expect( transformed[ 5 ] ).toEqual( thirdExpectedBlock ); + } ); + it( 'should convert two instances of the same shortcode', () => { const original = `

[foo one]

[foo two]

`;