UNPKG

@wordpress/blocks

Version:
682 lines (565 loc) 22.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.isOfType = isOfType; exports.isOfTypes = isOfTypes; exports.isValidByType = isValidByType; exports.isValidByEnum = isValidByEnum; exports.isAmbiguousStringSource = isAmbiguousStringSource; exports.matcherFromSource = matcherFromSource; exports.parseWithAttributeSchema = parseWithAttributeSchema; exports.getBlockAttribute = getBlockAttribute; exports.getBlockAttributes = getBlockAttributes; exports.getMigratedBlock = getMigratedBlock; exports.convertLegacyBlocks = convertLegacyBlocks; exports.createBlockWithFallback = createBlockWithFallback; exports.serializeBlockNode = serializeBlockNode; exports.default = exports.parseWithGrammar = exports.toBooleanAttributeMatcher = void 0; var _hpq = require("hpq"); var _lodash = require("lodash"); var _autop = require("@wordpress/autop"); var _hooks = require("@wordpress/hooks"); var _blockSerializationDefaultParser = require("@wordpress/block-serialization-default-parser"); var _registration = require("./registration"); var _factory = require("./factory"); var _validation = require("./validation"); var _serializer = require("./serializer"); var _matchers = require("./matchers"); var _utils = require("./utils"); var _constants = require("./constants"); /** * External dependencies */ /** * WordPress dependencies */ /** * Internal dependencies */ /** * Sources which are guaranteed to return a string value. * * @type {Set} */ const STRING_SOURCES = new Set(['attribute', 'html', 'text', 'tag']); /** * Higher-order hpq matcher which enhances an attribute matcher to return true * or false depending on whether the original matcher returns undefined. This * is useful for boolean attributes (e.g. disabled) whose attribute values may * be technically falsey (empty string), though their mere presence should be * enough to infer as true. * * @param {Function} matcher Original hpq matcher. * * @return {Function} Enhanced hpq matcher. */ const toBooleanAttributeMatcher = matcher => (0, _lodash.flow)([matcher, // Expected values from `attr( 'disabled' )`: // // <input> // - Value: `undefined` // - Transformed: `false` // // <input disabled> // - Value: `''` // - Transformed: `true` // // <input disabled="disabled"> // - Value: `'disabled'` // - Transformed: `true` value => value !== undefined]); /** * Returns true if value is of the given JSON schema type, or false otherwise. * * @see http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.25 * * @param {*} value Value to test. * @param {string} type Type to test. * * @return {boolean} Whether value is of type. */ exports.toBooleanAttributeMatcher = toBooleanAttributeMatcher; function isOfType(value, type) { switch (type) { case 'string': return typeof value === 'string'; case 'boolean': return typeof value === 'boolean'; case 'object': return !!value && value.constructor === Object; case 'null': return value === null; case 'array': return Array.isArray(value); case 'integer': case 'number': return typeof value === 'number'; } return true; } /** * Returns true if value is of an array of given JSON schema types, or false * otherwise. * * @see http://json-schema.org/latest/json-schema-validation.html#rfc.section.6.25 * * @param {*} value Value to test. * @param {string[]} types Types to test. * * @return {boolean} Whether value is of types. */ function isOfTypes(value, types) { return types.some(type => isOfType(value, type)); } /** * Returns true if value is valid per the given block attribute schema type * definition, or false otherwise. * * @see https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.1.1 * * @param {*} value Value to test. * @param {?(Array<string>|string)} type Block attribute schema type. * * @return {boolean} Whether value is valid. */ function isValidByType(value, type) { return type === undefined || isOfTypes(value, (0, _lodash.castArray)(type)); } /** * Returns true if value is valid per the given block attribute schema enum * definition, or false otherwise. * * @see https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.1.2 * * @param {*} value Value to test. * @param {?Array} enumSet Block attribute schema enum. * * @return {boolean} Whether value is valid. */ function isValidByEnum(value, enumSet) { return !Array.isArray(enumSet) || enumSet.includes(value); } /** * Returns true if the given attribute schema describes a value which may be * an ambiguous string. * * Some sources are ambiguously serialized as strings, for which value casting * is enabled. This is only possible when a singular type is assigned to the * attribute schema, since the string ambiguity makes it impossible to know the * correct type of multiple to which to cast. * * @param {Object} attributeSchema Attribute's schema. * * @return {boolean} Whether attribute schema defines an ambiguous string * source. */ function isAmbiguousStringSource(attributeSchema) { const { source, type } = attributeSchema; const isStringSource = STRING_SOURCES.has(source); const isSingleType = typeof type === 'string'; return isStringSource && isSingleType; } /** * Returns an hpq matcher given a source object. * * @param {Object} sourceConfig Attribute Source object. * * @return {Function} A hpq Matcher. */ function matcherFromSource(sourceConfig) { switch (sourceConfig.source) { case 'attribute': let matcher = (0, _matchers.attr)(sourceConfig.selector, sourceConfig.attribute); if (sourceConfig.type === 'boolean') { matcher = toBooleanAttributeMatcher(matcher); } return matcher; case 'html': return (0, _matchers.html)(sourceConfig.selector, sourceConfig.multiline); case 'text': return (0, _matchers.text)(sourceConfig.selector); case 'children': return (0, _matchers.children)(sourceConfig.selector); case 'node': return (0, _matchers.node)(sourceConfig.selector); case 'query': const subMatchers = (0, _lodash.mapValues)(sourceConfig.query, matcherFromSource); return (0, _matchers.query)(sourceConfig.selector, subMatchers); case 'tag': return (0, _lodash.flow)([(0, _matchers.prop)(sourceConfig.selector, 'nodeName'), nodeName => nodeName ? nodeName.toLowerCase() : undefined]); default: // eslint-disable-next-line no-console console.error(`Unknown source type "${sourceConfig.source}"`); } } /** * Given a block's raw content and an attribute's schema returns the attribute's * value depending on its source. * * @param {string} innerHTML Block's raw content. * @param {Object} attributeSchema Attribute's schema. * * @return {*} Attribute value. */ function parseWithAttributeSchema(innerHTML, attributeSchema) { return (0, _hpq.parse)(innerHTML, matcherFromSource(attributeSchema)); } /** * Given an attribute key, an attribute's schema, a block's raw content and the * commentAttributes returns the attribute value depending on its source * definition of the given attribute key. * * @param {string} attributeKey Attribute key. * @param {Object} attributeSchema Attribute's schema. * @param {string} innerHTML Block's raw content. * @param {Object} commentAttributes Block's comment attributes. * * @return {*} Attribute value. */ function getBlockAttribute(attributeKey, attributeSchema, innerHTML, commentAttributes) { const { type, enum: enumSet } = attributeSchema; let value; switch (attributeSchema.source) { // An undefined source means that it's an attribute serialized to the // block's "comment". case undefined: value = commentAttributes ? commentAttributes[attributeKey] : undefined; break; case 'attribute': case 'property': case 'html': case 'text': case 'children': case 'node': case 'query': case 'tag': value = parseWithAttributeSchema(innerHTML, attributeSchema); break; } if (!isValidByType(value, type) || !isValidByEnum(value, enumSet)) { // Reject the value if it is not valid. Reverting to the undefined // value ensures the default is respected, if applicable. value = undefined; } if (value === undefined) { return attributeSchema.default; } return value; } /** * Returns the block attributes of a registered block node given its type. * * @param {string|Object} blockTypeOrName Block type or name. * @param {string} innerHTML Raw block content. * @param {?Object} attributes Known block attributes (from delimiters). * * @return {Object} All block attributes. */ function getBlockAttributes(blockTypeOrName, innerHTML, attributes = {}) { const blockType = (0, _utils.normalizeBlockType)(blockTypeOrName); const blockAttributes = (0, _lodash.mapValues)(blockType.attributes, (attributeSchema, attributeKey) => { return getBlockAttribute(attributeKey, attributeSchema, innerHTML, attributes); }); return (0, _hooks.applyFilters)('blocks.getBlockAttributes', blockAttributes, blockType, innerHTML, attributes); } /** * Given a block object, returns a new copy of the block with any applicable * deprecated migrations applied, or the original block if it was both valid * and no eligible migrations exist. * * @param {WPBlock} block Original block object. * @param {Object} parsedAttributes Attributes as parsed from the initial * block markup. * * @return {WPBlock} Migrated block object. */ function getMigratedBlock(block, parsedAttributes) { const blockType = (0, _registration.getBlockType)(block.name); const { deprecated: deprecatedDefinitions } = blockType; // Bail early if there are no registered deprecations to be handled. if (!deprecatedDefinitions || !deprecatedDefinitions.length) { return block; } const { originalContent, innerBlocks } = block; // By design, blocks lack any sort of version tracking. Instead, to process // outdated content the system operates a queue out of all the defined // attribute shapes and tries each definition until the input produces a // valid result. This mechanism seeks to avoid polluting the user-space with // machine-specific code. An invalid block is thus a block that could not be // matched successfully with any of the registered deprecation definitions. for (let i = 0; i < deprecatedDefinitions.length; i++) { // A block can opt into a migration even if the block is valid by // defining `isEligible` on its deprecation. If the block is both valid // and does not opt to migrate, skip. const { isEligible = _lodash.stubFalse } = deprecatedDefinitions[i]; if (block.isValid && !isEligible(parsedAttributes, innerBlocks)) { continue; } // Block type properties which could impact either serialization or // parsing are not considered in the deprecated block type by default, // and must be explicitly provided. const deprecatedBlockType = Object.assign((0, _lodash.omit)(blockType, _constants.DEPRECATED_ENTRY_KEYS), deprecatedDefinitions[i]); let migratedAttributes = getBlockAttributes(deprecatedBlockType, originalContent, parsedAttributes); // Ignore the deprecation if it produces a block which is not valid. const { isValid, validationIssues } = (0, _validation.getBlockContentValidationResult)(deprecatedBlockType, migratedAttributes, originalContent); // An invalid block does not imply incorrect HTML but the fact block // source information could be lost on reserialization. if (!isValid) { block = { ...block, validationIssues: [...(0, _lodash.get)(block, 'validationIssues', []), ...validationIssues] }; continue; } let migratedInnerBlocks = innerBlocks; // A block may provide custom behavior to assign new attributes and/or // inner blocks. const { migrate } = deprecatedBlockType; if (migrate) { [migratedAttributes = parsedAttributes, migratedInnerBlocks = innerBlocks] = (0, _lodash.castArray)(migrate(migratedAttributes, innerBlocks)); } block = { ...block, attributes: migratedAttributes, innerBlocks: migratedInnerBlocks, isValid: true }; } return block; } /** * Convert legacy blocks to their canonical form. This function is used * both in the parser level for previous content and to convert such blocks * used in Custom Post Types templates. * * @param {string} name The block's name * @param {Object} attributes The block's attributes * * @return {Object} The block's name and attributes, changed accordingly if a match was found */ function convertLegacyBlocks(name, attributes) { const newAttributes = { ...attributes }; // Convert 'core/cover-image' block in existing content to 'core/cover'. if ('core/cover-image' === name) { name = 'core/cover'; } // Convert 'core/text' blocks in existing content to 'core/paragraph'. if ('core/text' === name || 'core/cover-text' === name) { name = 'core/paragraph'; } // Convert derivative blocks such as 'core/social-link-wordpress' to the // canonical form 'core/social-link'. if (name && name.indexOf('core/social-link-') === 0) { // Capture `social-link-wordpress` into `{"service":"wordpress"}` newAttributes.service = name.substring(17); name = 'core/social-link'; } // Convert derivative blocks such as 'core-embed/instagram' to the // canonical form 'core/embed'. if (name && name.indexOf('core-embed/') === 0) { // Capture `core-embed/instagram` into `{"providerNameSlug":"instagram"}` const providerSlug = name.substring(11); const deprecated = { speaker: 'speaker-deck', polldaddy: 'crowdsignal' }; newAttributes.providerNameSlug = providerSlug in deprecated ? deprecated[providerSlug] : providerSlug; // this is needed as the `responsive` attribute was passed // in a different way before the refactoring to block variations if (!['amazon-kindle', 'wordpress'].includes(providerSlug)) { newAttributes.responsive = true; } name = 'core/embed'; } return { name, attributes: newAttributes }; } /** * Creates a block with fallback to the unknown type handler. * * @param {Object} blockNode Parsed block node. * * @return {?Object} An initialized block object (if possible). */ function createBlockWithFallback(blockNode) { const { blockName: originalName } = blockNode; // The fundamental structure of a blocktype includes its attributes, inner // blocks, and inner HTML. It is important to distinguish inner blocks from // the HTML content of the block as only the latter is relevant for block // validation and edit operations. let { attrs: attributes, innerBlocks = [], innerHTML } = blockNode; const { innerContent } = blockNode; // Blocks that don't have a registered handler are considered freeform. const freeformContentFallbackBlock = (0, _registration.getFreeformContentHandlerName)(); const unregisteredFallbackBlock = (0, _registration.getUnregisteredTypeHandlerName)() || freeformContentFallbackBlock; attributes = attributes || {}; // Trim content to avoid creation of intermediary freeform segments. innerHTML = innerHTML.trim(); // Use type from block content if available. Otherwise, default to the // freeform content fallback. let name = originalName || freeformContentFallbackBlock; ({ name, attributes } = convertLegacyBlocks(name, attributes)); // Fallback content may be upgraded from classic content expecting implicit // automatic paragraphs, so preserve them. Assumes wpautop is idempotent, // meaning there are no negative consequences to repeated autop calls. if (name === freeformContentFallbackBlock) { innerHTML = (0, _autop.autop)(innerHTML).trim(); } // Try finding the type for known block name, else fall back again. let blockType = (0, _registration.getBlockType)(name); if (!blockType) { // Since the constituents of the block node are extracted at the start // of the present function, construct a new object rather than reuse // `blockNode`. const reconstitutedBlockNode = { attrs: attributes, blockName: originalName, innerBlocks, innerContent }; // Preserve undelimited content for use by the unregistered type // handler. A block node's `innerHTML` isn't enough, as that field only // carries the block's own HTML and not its nested blocks. const originalUndelimitedContent = serializeBlockNode(reconstitutedBlockNode, { isCommentDelimited: false }); // Preserve full block content for use by the unregistered type // handler, block boundaries included. const originalContent = serializeBlockNode(reconstitutedBlockNode, { isCommentDelimited: true }); // If detected as a block which is not registered, preserve comment // delimiters in content of unregistered type handler. if (name) { innerHTML = originalContent; } name = unregisteredFallbackBlock; attributes = { originalName, originalContent, originalUndelimitedContent }; blockType = (0, _registration.getBlockType)(name); } // Coerce inner blocks from parsed form to canonical form. innerBlocks = innerBlocks.map(createBlockWithFallback); // Remove `undefined` innerBlocks. // // This is a temporary fix to prevent unrecoverable TypeErrors when handling unexpectedly // empty freeform block nodes. See https://github.com/WordPress/gutenberg/pull/17164. innerBlocks = innerBlocks.filter(innerBlock => innerBlock); const isFallbackBlock = name === freeformContentFallbackBlock || name === unregisteredFallbackBlock; // Include in set only if type was determined. if (!blockType || !innerHTML && isFallbackBlock) { return; } let block = (0, _factory.createBlock)(name, getBlockAttributes(blockType, innerHTML, attributes), innerBlocks); // Block validation assumes an idempotent operation from source block to serialized block // provided there are no changes in attributes. The validation procedure thus compares the // provided source value with the serialized output before there are any modifications to // the block. When both match, the block is marked as valid. if (!isFallbackBlock) { const { isValid, validationIssues } = (0, _validation.getBlockContentValidationResult)(blockType, block.attributes, innerHTML); block.isValid = isValid; block.validationIssues = validationIssues; } // Preserve original content for future use in case the block is parsed // as invalid, or future serialization attempt results in an error. block.originalContent = block.originalContent || innerHTML; // Ensure all necessary migrations are applied to the block. block = getMigratedBlock(block, attributes); if (block.validationIssues && block.validationIssues.length > 0) { if (block.isValid) { // eslint-disable-next-line no-console console.info('Block successfully updated for `%s` (%o).\n\nNew content generated by `save` function:\n\n%s\n\nContent retrieved from post body:\n\n%s', blockType.name, blockType, (0, _serializer.getSaveContent)(blockType, block.attributes), block.originalContent); } else { block.validationIssues.forEach(({ log, args }) => log(...args)); } } return block; } /** * Serializes a block node into the native HTML-comment-powered block format. * CAVEAT: This function is intended for reserializing blocks as parsed by * valid parsers and skips any validation steps. This is NOT a generic * serialization function for in-memory blocks. For most purposes, see the * following functions available in the `@wordpress/blocks` package: * * @see serializeBlock * @see serialize * * For more on the format of block nodes as returned by valid parsers: * * @see `@wordpress/block-serialization-default-parser` package * @see `@wordpress/block-serialization-spec-parser` package * * @param {Object} blockNode A block node as returned by a valid parser. * @param {?Object} options Serialization options. * @param {?boolean} options.isCommentDelimited Whether to output HTML comments around blocks. * * @return {string} An HTML string representing a block. */ function serializeBlockNode(blockNode, options = {}) { const { isCommentDelimited = true } = options; const { blockName, attrs = {}, innerBlocks = [], innerContent = [] } = blockNode; let childIndex = 0; const content = innerContent.map(item => // `null` denotes a nested block, otherwise we have an HTML fragment. item !== null ? item : serializeBlockNode(innerBlocks[childIndex++], options)).join('\n').replace(/\n+/g, '\n').trim(); return isCommentDelimited ? (0, _serializer.getCommentDelimitedContent)(blockName, attrs, content) : content; } /** * Creates a parse implementation for the post content which returns a list of blocks. * * @param {Function} parseImplementation Parse implementation. * * @return {Function} An implementation which parses the post content. */ const createParse = parseImplementation => content => parseImplementation(content).reduce((accumulator, blockNode) => { const block = createBlockWithFallback(blockNode); if (block) { accumulator.push(block); } return accumulator; }, []); /** * Utilizes an optimized token-driven parser based on the Gutenberg grammar spec * defined through a parsing expression grammar to take advantage of the regular * cadence provided by block delimiters -- composed syntactically through HTML * comments -- which, given a general HTML document as an input, returns a block * list array representation. * * This is a recursive-descent parser that scans linearly once through the input * document. Instead of directly recursing it utilizes a trampoline mechanism to * prevent stack overflow. This initial pass is mainly interested in separating * and isolating the blocks serialized in the document and manifestly not in the * content within the blocks. * * @see * https://developer.wordpress.org/block-editor/packages/packages-block-serialization-default-parser/ * * @param {string} content The post content. * * @return {Array} Block list. */ const parseWithGrammar = createParse(_blockSerializationDefaultParser.parse); exports.parseWithGrammar = parseWithGrammar; var _default = parseWithGrammar; exports.default = _default; //# sourceMappingURL=parser.js.map