diff --git a/blocks/api/index.js b/blocks/api/index.js index 5d010d41932d7e..59b05c28450da6 100644 --- a/blocks/api/index.js +++ b/blocks/api/index.js @@ -1,9 +1,7 @@ /** * External dependencies */ -import * as query from './query'; - -export { query }; +export { default as query } from './query'; export { createBlock, switchToBlockType } from './factory'; export { default as parse } from './parser'; export { default as serialize } from './serializer'; diff --git a/blocks/api/parser.js b/blocks/api/parser.js index fd38a8e2e53740..4f7a4d56442849 100644 --- a/blocks/api/parser.js +++ b/blocks/api/parser.js @@ -2,7 +2,7 @@ * External dependencies */ import { parse as hpqParse } from 'hpq'; -import { escape, unescape, pickBy } from 'lodash'; +import { escape, unescape, reduce } from 'lodash'; /** * Internal dependencies @@ -11,32 +11,6 @@ import { parse as grammarParse } from './post.pegjs'; import { getBlockSettings, getUnknownTypeHandler } from './registration'; import { createBlock } from './factory'; -/** - * Returns the block attributes parsed from raw content. - * - * @param {String} rawContent Raw block content - * @param {Object} blockSettings Block settings - * @return {Object} Block attributes - */ -export function parseBlockAttributes( rawContent, blockSettings ) { - const { attributes } = blockSettings; - if ( 'function' === typeof attributes ) { - return attributes( rawContent ); - } else if ( attributes ) { - // Matchers are implemented as functions that receive a DOM node from - // which to select data. Use of the DOM is incidental and we shouldn't - // guarantee a contract that this be provided, else block implementers - // may feel compelled to use the node. Instead, matchers are intended - // as a generic interface to query data from any tree shape. Here we - // pick only matchers which include an internal flag. - const knownMatchers = pickBy( attributes, '_wpBlocksKnownMatcher' ); - - return hpqParse( rawContent, knownMatchers ); - } - - return {}; -} - /** * Returns the block attributes of a registered block node given its settings. * @@ -46,17 +20,23 @@ export function parseBlockAttributes( rawContent, blockSettings ) { * @return {Object} All block attributes */ export function getBlockAttributes( blockSettings, rawContent, attributes ) { - // Merge any attributes from comment delimiters with block implementation - attributes = attributes || {}; - if ( blockSettings ) { - attributes = { - ...attributes, - ...blockSettings.defaultAttributes, - ...parseBlockAttributes( rawContent, blockSettings ), - }; - } + // The blockSettings.attributes contains the definition of each attribute + // depending on its "source", we retrieve its value from the comment attribute + // or by parsing the block content + const computedAttributes = reduce( blockSettings.attributes, ( memo, attribute, key ) => { + if ( attribute.source === 'metadata' ) { + memo[ key ] = attributes[ attribute.name || key ]; + } else if ( attribute.source === 'content' ) { + memo[ key ] = hpqParse( rawContent, attribute.parse ); + } + + return memo; + }, {} ); - return attributes; + return { + ...blockSettings.defaultAttributes, + ...computedAttributes, + }; } /** diff --git a/blocks/api/query.js b/blocks/api/query.js index f9e7782660de96..bae58c1e29d20f 100644 --- a/blocks/api/query.js +++ b/blocks/api/query.js @@ -2,7 +2,6 @@ * External dependencies */ import { nodeListToReact } from 'dom-react'; -import { flow } from 'lodash'; import { attr as originalAttr, prop as originalProp, @@ -10,27 +9,9 @@ import { text as originalText, query as originalQuery } from 'hpq'; +import { reduce } from 'lodash'; -/** - * Given a matcher function creator, returns a new function which applies an - * internal flag to the created matcher. - * - * @param {Function} fn Original matcher function creator - * @return {Function} Modified matcher function creator - */ -function withKnownMatcherFlag( fn ) { - return flow( fn, ( matcher ) => { - matcher._wpBlocksKnownMatcher = true; - return matcher; - } ); -} - -export const attr = withKnownMatcherFlag( originalAttr ); -export const prop = withKnownMatcherFlag( originalProp ); -export const html = withKnownMatcherFlag( originalHtml ); -export const text = withKnownMatcherFlag( originalText ); -export const query = withKnownMatcherFlag( originalQuery ); -export const children = withKnownMatcherFlag( ( selector ) => { +export const originalChildren = ( selector ) => { return ( node ) => { let match = node; @@ -44,4 +25,51 @@ export const children = withKnownMatcherFlag( ( selector ) => { return []; }; -} ); +}; + +const addDescriptor = ( description ) => ( memo ) => { + return Object.assign( memo, description ); +}; + +// Source descriptors +// Each one of these functions defines how to retrieve the attribute value +// +// - the descriptor sets "source: content" and a parse function for attributes parsed from block content +// - the descriptor sets "source: metadata" and an attribute name for attributes stored in the block comment +const attr = ( ...args ) => addDescriptor( { source: 'content', parse: originalAttr( ...args ) } ); +const prop = ( ...args ) => addDescriptor( { source: 'content', parse: originalProp( ...args ) } ); +const html = ( ...args ) => addDescriptor( { source: 'content', parse: originalHtml( ...args ) } ); +const text = ( ...args ) => addDescriptor( { source: 'content', parse: originalText( ...args ) } ); +const children = ( ...args ) => addDescriptor( { source: 'content', parse: originalChildren( ...args ) } ); +const metadata = ( name ) => addDescriptor( { source: 'metadata', name } ); +const query = ( selector, descriptor ) => { + return addDescriptor( { + source: 'content', + parse: originalQuery( selector, descriptor.__description.parse ) + } ); +}; + +/** + * Takes an argument description and returns a chainable API to describe the current attribute + * + * @param {?Object} description The argument description + * + * @return {Object} descriptors chainable API + */ +const getChainableAPI = ( description ) => { + return reduce( { attr, prop, html, text, query, children, metadata }, ( memo, fct, key ) => { + const wrappedFct = ( ...args ) => { + const accumulator = fct( ...args ); + const newDescription = accumulator( description || {} ); + return { + ...getChainableAPI( newDescription ), + __description: newDescription + }; + }; + + memo[ key ] = wrappedFct; + return memo; + }, {} ); +}; + +export default getChainableAPI(); diff --git a/blocks/api/registration.js b/blocks/api/registration.js index 3dcd1f86b356c6..23fcdba8cc21f6 100644 --- a/blocks/api/registration.js +++ b/blocks/api/registration.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { reduce } from 'lodash'; + /* eslint-disable no-console */ /** @@ -43,7 +48,13 @@ export function registerBlock( slug, settings ) { ); return; } - const block = Object.assign( { slug }, settings ); + + const attributes = reduce( settings ? settings.attributes : {}, ( memo, value, key ) => { + memo[ key ] = value.__description; + return memo; + }, {} ); + + const block = Object.assign( {}, settings, { slug, attributes } ); blocks[ slug ] = block; return block; } diff --git a/blocks/api/serializer.js b/blocks/api/serializer.js index 280f9dfcb49464..db6025479dcbcb 100644 --- a/blocks/api/serializer.js +++ b/blocks/api/serializer.js @@ -1,14 +1,13 @@ /** * External dependencies */ -import { difference } from 'lodash'; +import { reduce } from 'lodash'; import { html as beautifyHtml } from 'js-beautify'; /** * Internal dependencies */ import { getBlockSettings } from './registration'; -import { parseBlockAttributes } from './parser'; /** * Given a block's save render implementation and attributes, returns the @@ -37,29 +36,21 @@ export function getSaveContent( save, attributes ) { } /** - * Returns comment attributes as serialized string, determined by subset of - * difference between actual attributes of a block and those expected based - * on its settings. + * Returns comment attributes as serialized string * - * @param {Object} realAttributes Actual block attributes - * @param {Object} expectedAttributes Expected block attributes - * @return {string} Comment attributes + * @param {Object} settings Block settings + * @param {Object} attributes Block attributes + * @return {string} Comment attributes */ -export function getCommentAttributes( realAttributes, expectedAttributes ) { - // Find difference and build into object subset of attributes. - const keys = difference( - Object.keys( realAttributes ), - Object.keys( expectedAttributes ) - ); - +export function getCommentAttributes( settings, attributes ) { // Serialize the comment attributes - return keys.reduce( ( memo, key ) => { - const value = realAttributes[ key ]; - if ( undefined === value ) { - return memo; + return reduce( settings.attributes, ( memo, attribute, key ) => { + const value = attributes[ key ]; + if ( attribute.source === 'metadata' && value !== undefined ) { + return memo + `${ attribute.name || key }="${ value }" `; } - return memo + `${ key }="${ value }" `; + return memo; }, '' ); } @@ -84,8 +75,8 @@ export default function serialize( blocks ) { blockType + ' ' + getCommentAttributes( + settings, block.attributes, - parseBlockAttributes( saveContent, settings ) ) + '-->' + ( saveContent ? '\n' + beautifyHtml( saveContent, beautifyOptions ) + '\n' : '' ) + diff --git a/blocks/api/test/parser.js b/blocks/api/test/parser.js index 234554e7af11e9..2a2fe49b16c985 100644 --- a/blocks/api/test/parser.js +++ b/blocks/api/test/parser.js @@ -2,14 +2,14 @@ * External dependencies */ import { expect } from 'chai'; +import { text } from 'hpq'; /** * Internal dependencies */ -import { text } from '../query'; +import query from '../query'; import { getBlockAttributes, - parseBlockAttributes, createBlockWithFallback, parseWithGrammar, parseWithTinyMCE @@ -29,71 +29,42 @@ describe( 'block parser', () => { } ); } ); - describe( 'parseBlockAttributes()', () => { - it( 'should use the function implementation', () => { - const blockSettings = { - attributes: function( rawContent ) { - return { - content: rawContent + ' & Chicken' - }; - } - }; - - expect( parseBlockAttributes( 'Ribs', blockSettings ) ).to.eql( { - content: 'Ribs & Chicken' - } ); - } ); - - it( 'should use the query object implementation', () => { - const blockSettings = { - attributes: { - emphasis: text( 'strong' ), - ignoredDomMatcher: ( node ) => node.innerHTML - } - }; - - const rawContent = 'Ribs & Chicken'; - - expect( parseBlockAttributes( rawContent, blockSettings ) ).to.eql( { - emphasis: '& Chicken' - } ); - } ); - - it( 'should return an empty object if no attributes defined', () => { - const blockSettings = {}; - const rawContent = 'Ribs & Chicken'; - - expect( parseBlockAttributes( rawContent, blockSettings ) ).to.eql( {} ); - } ); - } ); - describe( 'getBlockAttributes()', () => { it( 'should merge attributes with the parsed and default attributes', () => { const blockSettings = { - attributes: function( rawContent ) { - return { - content: rawContent + ' & Chicken' - }; + attributes: { + content: { + source: 'content', + parse: text( 'strong' ) + }, + align: { + source: 'metadata', + name: 'alignment' + } }, defaultAttributes: { topic: 'none' } }; - const rawContent = 'Ribs'; - const attrs = { align: 'left' }; + const rawContent = 'Ribs'; + const attrs = { alignment: 'left' }; expect( getBlockAttributes( blockSettings, rawContent, attrs ) ).to.eql( { align: 'left', topic: 'none', - content: 'Ribs & Chicken' + content: 'Ribs' } ); } ); } ); describe( 'createBlockWithFallback', () => { it( 'should create the requested block if it exists', () => { - registerBlock( 'core/test-block', {} ); + registerBlock( 'core/test-block', { + attributes: { + attr: query.metadata( 'attr' ) + } + } ); const block = createBlockWithFallback( 'core/test-block', @@ -122,7 +93,7 @@ describe( 'block parser', () => { { attr: 'value' } ); expect( block.blockType ).to.eql( 'core/unknown-block' ); - expect( block.attributes ).to.eql( { attr: 'value' } ); + expect( block.attributes ).to.eql( {} ); } ); it( 'should fall back to the unknown type handler if block type not specified', () => { @@ -148,16 +119,18 @@ describe( 'block parser', () => { it( 'should parse the post content, including block attributes', () => { registerBlock( 'core/test-block', { // Currently this is the only way to test block content parsing? - attributes: function( rawContent ) { - return { - content: rawContent, - }; + attributes: { + content: query.text( 'strong' ), + smoked: query.metadata( 'smoked' ), + url: query.metadata( 'url' ), + chicken: query.metadata( 'chicken' ), + checked: query.metadata( 'checked' ) } } ); const parsed = parse( '' + - 'Brisket' + + 'Brisket' + '' ); @@ -175,15 +148,13 @@ describe( 'block parser', () => { it( 'should parse the post content, ignoring unknown blocks', () => { registerBlock( 'core/test-block', { - attributes: function( rawContent ) { - return { - content: rawContent + ' & Chicken' - }; + attributes: { + content: query.text( 'strong' ), } } ); const parsed = parse( - '\nRibs\n' + + '\nRibs\n' + '
Broccoli
' + 'Ribs' ); @@ -191,7 +162,7 @@ describe( 'block parser', () => { expect( parsed ).to.have.lengthOf( 1 ); expect( parsed[ 0 ].blockType ).to.equal( 'core/test-block' ); expect( parsed[ 0 ].attributes ).to.eql( { - content: 'Ribs & Chicken' + content: 'Ribs' } ); expect( parsed[ 0 ].uid ).to.be.a( 'string' ); } ); @@ -219,11 +190,8 @@ describe( 'block parser', () => { it( 'should parse the post content, including raw HTML at each end', () => { registerBlock( 'core/test-block', {} ); registerBlock( 'core/unknown-block', { - // Currently this is the only way to test block content parsing? - attributes: function( rawContent ) { - return { - content: rawContent, - }; + attributes: { + content: query.html(), } } ); diff --git a/blocks/api/test/query.js b/blocks/api/test/query.js index e372cf1df66305..8dde36a80be131 100644 --- a/blocks/api/test/query.js +++ b/blocks/api/test/query.js @@ -7,18 +7,12 @@ import { parse } from 'hpq'; /** * Internal dependencies */ -import * as query from '../query'; +import { originalChildren } from '../query'; describe( 'query', () => { - it( 'should generate matchers which apply internal flag', () => { - for ( const matcherFn in query ) { - expect( query[ matcherFn ]()._wpBlocksKnownMatcher ).to.be.true(); - } - } ); - describe( 'children()', () => { it( 'should return a matcher function', () => { - const matcher = query.children(); + const matcher = originalChildren; expect( matcher ).to.be.a( 'function' ); } ); @@ -27,7 +21,7 @@ describe( 'query', () => { // Assumption here is that we can cleanly convert back and forth // between a string and WPElement representation const html = ''; - const match = parse( html, query.children() ); + const match = parse( html, originalChildren() ); expect( wp.element.renderToString( match ) ).to.equal( html ); } ); diff --git a/blocks/api/test/registration.js b/blocks/api/test/registration.js index 3af2d10dfe6c18..c7bce3008a69a0 100644 --- a/blocks/api/test/registration.js +++ b/blocks/api/test/registration.js @@ -18,6 +18,8 @@ import { getBlocks } from '../registration'; +import query from '../query'; + describe( 'blocks', () => { // Reset block state before each test. beforeEach( () => { @@ -54,7 +56,7 @@ describe( 'blocks', () => { it( 'should accept valid block names', () => { const block = registerBlock( 'my-plugin/fancy-block-4' ); expect( console.error ).to.not.have.been.called(); - expect( block ).to.eql( { slug: 'my-plugin/fancy-block-4' } ); + expect( block ).to.eql( { slug: 'my-plugin/fancy-block-4', attributes: {} } ); } ); it( 'should prohibit registering the same block twice', () => { @@ -71,6 +73,22 @@ describe( 'blocks', () => { expect( getBlockSettings( 'core/test-block-with-settings' ) ).to.eql( { slug: 'core/test-block-with-settings', settingName: 'settingValue', + attributes: {} + } ); + } ); + + it( 'should compute block attributes', () => { + const blockSettings = { settingName: 'settingValue', attributes: { + align: query.metadata( 'align' ) + } }; + registerBlock( 'core/test-block-with-settings', blockSettings ); + blockSettings.mutated = true; + expect( getBlockSettings( 'core/test-block-with-settings' ) ).to.eql( { + slug: 'core/test-block-with-settings', + settingName: 'settingValue', + attributes: { + align: { source: 'metadata', name: 'align' } + } } ); } ); } ); @@ -85,11 +103,11 @@ describe( 'blocks', () => { it( 'should unregister existing blocks', () => { registerBlock( 'core/test-block' ); expect( getBlocks() ).to.eql( [ - { slug: 'core/test-block' }, + { slug: 'core/test-block', attributes: {} }, ] ); const oldBlock = unregisterBlock( 'core/test-block' ); expect( console.error ).to.not.have.been.called(); - expect( oldBlock ).to.eql( { slug: 'core/test-block' } ); + expect( oldBlock ).to.eql( { slug: 'core/test-block', attributes: {} } ); expect( getBlocks() ).to.eql( [] ); } ); } ); @@ -113,6 +131,7 @@ describe( 'blocks', () => { registerBlock( 'core/test-block' ); expect( getBlockSettings( 'core/test-block' ) ).to.eql( { slug: 'core/test-block', + attributes: {} } ); } ); @@ -122,6 +141,7 @@ describe( 'blocks', () => { expect( getBlockSettings( 'core/test-block-with-settings' ) ).to.eql( { slug: 'core/test-block-with-settings', settingName: 'settingValue', + attributes: {} } ); } ); } ); @@ -136,8 +156,8 @@ describe( 'blocks', () => { const blockSettings = { settingName: 'settingValue' }; registerBlock( 'core/test-block-with-settings', blockSettings ); expect( getBlocks() ).to.eql( [ - { slug: 'core/test-block' }, - { slug: 'core/test-block-with-settings', settingName: 'settingValue' }, + { slug: 'core/test-block', attributes: {} }, + { slug: 'core/test-block-with-settings', settingName: 'settingValue', attributes: {} }, ] ); } ); } ); diff --git a/blocks/api/test/serializer.js b/blocks/api/test/serializer.js index 59a71ca0f5199e..f787ccfa5224db 100644 --- a/blocks/api/test/serializer.js +++ b/blocks/api/test/serializer.js @@ -8,6 +8,7 @@ import { expect } from 'chai'; */ import serialize, { getCommentAttributes, getSaveContent } from '../serializer'; import { getBlocks, registerBlock, unregisterBlock } from '../registration'; +import query from '../query'; describe( 'block serializer', () => { afterEach( () => { @@ -64,36 +65,40 @@ describe( 'block serializer', () => { it( 'should return joined string of key:value pairs by difference subset', () => { const attributes = getCommentAttributes( { + attributes: { + category: { source: 'metadata', name: 'cat' }, + ripeness: { source: 'metadata', name: 'ripeness' }, + } + }, { fruit: 'bananas', category: 'food', ripeness: 'ripe' - }, { - fruit: 'bananas' } ); - expect( attributes ).to.equal( 'category="food" ripeness="ripe" ' ); + expect( attributes ).to.equal( 'cat="food" ripeness="ripe" ' ); } ); it( 'should not append an undefined attribute value', () => { const attributes = getCommentAttributes( { + attributes: { + category: { source: 'metadata', name: 'cat' }, + ripeness: { source: 'metadata', name: 'ripeness' }, + } + }, { fruit: 'bananas', category: 'food', ripeness: undefined - }, { - fruit: 'bananas' } ); - expect( attributes ).to.equal( 'category="food" ' ); + expect( attributes ).to.equal( 'cat="food" ' ); } ); } ); describe( 'serialize()', () => { it( 'should serialize the post content properly', () => { const blockSettings = { - attributes: ( rawContent ) => { - return { - content: rawContent - }; + attributes: { + align: query.metadata( 'align' ) }, save( { attributes } ) { return ; diff --git a/blocks/library/button/index.js b/blocks/library/button/index.js index 0dc22cc14938e7..c13bea5a912a89 100644 --- a/blocks/library/button/index.js +++ b/blocks/library/button/index.js @@ -6,7 +6,7 @@ import { registerBlock, query } from 'api'; import Editable from 'components/editable'; import IconButton from '../../../editor/components/icon-button'; -const { attr, children } = query; +const { attr, children, metadata } = query; /** * Returns an attribute setter with behavior that if the target value is @@ -32,7 +32,8 @@ registerBlock( 'core/button', { attributes: { url: attr( 'a', 'href' ), title: attr( 'a', 'title' ), - text: children( 'a' ) + text: children( 'a' ), + align: metadata() }, controls: [ diff --git a/blocks/library/image/index.js b/blocks/library/image/index.js index d3855096de3573..d0e981663d0d85 100644 --- a/blocks/library/image/index.js +++ b/blocks/library/image/index.js @@ -8,7 +8,7 @@ import Editable from 'components/editable'; import Dashicon from '../../../editor/components/dashicon'; import Button from '../../../editor/components/button'; -const { attr, children } = query; +const { attr, children, metadata } = query; /** * Returns an attribute setter with behavior that if the target value is @@ -34,7 +34,8 @@ registerBlock( 'core/image', { attributes: { url: attr( 'img', 'src' ), alt: attr( 'img', 'alt' ), - caption: children( 'figcaption' ) + caption: children( 'figcaption' ), + align: metadata() }, controls: [ diff --git a/blocks/library/quote/index.js b/blocks/library/quote/index.js index 690dfd7946d95b..23a9e8a2206d25 100644 --- a/blocks/library/quote/index.js +++ b/blocks/library/quote/index.js @@ -5,7 +5,7 @@ import './style.scss'; import { registerBlock, query as hpq } from 'api'; import Editable from 'components/editable'; -const { children, query } = hpq; +const { children, query, metadata } = hpq; registerBlock( 'core/quote', { title: wp.i18n.__( 'Quote' ), @@ -14,7 +14,8 @@ registerBlock( 'core/quote', { attributes: { value: query( 'blockquote > p', children() ), - citation: children( 'footer' ) + citation: children( 'footer' ), + style: metadata() }, controls: [ 1, 2 ].map( ( variation ) => ( {A delicious sundae dessert