@wordpress/blocks
Version:
Block API for WordPress.
318 lines (292 loc) • 8.09 kB
text/typescript
/**
* External dependencies
*/
// @ts-ignore
import { parse as hpqParse } from 'hpq';
import memoize from 'memize';
/**
* WordPress dependencies
*/
import { applyFilters } from '@wordpress/hooks';
import { RichTextData } from '@wordpress/rich-text';
/**
* Internal dependencies
*/
import {
attr,
html,
text,
query,
node,
children,
prop,
richText,
} from '../matchers';
import { normalizeBlockType, getDefault } from '../utils';
import type { BlockAttribute, BlockType } from '../../types';
/**
* 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 matcher Original hpq matcher.
*
* @return Enhanced hpq matcher.
*/
export const toBooleanAttributeMatcher =
( matcher: ( value: unknown ) => unknown ) =>
( value: unknown ): boolean =>
matcher( 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 type Type to test.
*
* @return Whether value is of type.
*/
export function isOfType( value: unknown, type: string ): boolean {
switch ( type ) {
case 'rich-text':
return value instanceof RichTextData;
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 types Types to test.
*
* @return Whether value is of types.
*/
export function isOfTypes( value: unknown, types: string[] ): boolean {
return types.some( ( type ) => isOfType( value, type ) );
}
/**
* 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 attributeKey Attribute key.
* @param attributeSchema Attribute's schema.
* @param innerDOM Parsed DOM of block's inner HTML.
* @param commentAttributes Block's comment attributes.
* @param innerHTML Raw HTML from block node's innerHTML property.
*
* @return Attribute value.
*/
export function getBlockAttribute(
attributeKey: string,
attributeSchema: BlockAttribute,
innerDOM: Node,
commentAttributes: Record< string, unknown >,
innerHTML: string | Node
): unknown {
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;
// raw source means that it's the original raw block content.
case 'raw':
value = innerHTML;
break;
case 'attribute':
case 'property':
case 'html':
case 'text':
case 'rich-text':
case 'children':
case 'node':
case 'query':
case 'tag':
value = parseWithAttributeSchema( innerDOM, attributeSchema );
break;
}
if (
! isValidByType( value, attributeSchema.type ) ||
! isValidByEnum( value, attributeSchema.enum )
) {
// 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 ) {
value = getDefault( attributeSchema );
}
return value;
}
/**
* 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 type Block attribute schema type.
*
* @return Whether value is valid.
*/
export function isValidByType(
value: unknown,
type: string | string[] | undefined
): boolean {
return (
type === undefined ||
isOfTypes( value, Array.isArray( type ) ? type : [ 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 enumSet Block attribute schema enum.
*
* @return Whether value is valid.
*/
export function isValidByEnum(
value: unknown,
enumSet: unknown[] | undefined
): boolean {
return ! Array.isArray( enumSet ) || enumSet.includes( value );
}
// Returns an hpq matcher given a source object.
export const matcherFromSource = memoize(
(
sourceConfig: BlockAttribute
): ( ( domNode: Element ) => unknown ) | undefined => {
switch ( sourceConfig.source ) {
case 'attribute': {
let matcher = attr(
sourceConfig.selector,
sourceConfig.attribute
);
if ( sourceConfig.type === 'boolean' ) {
matcher = toBooleanAttributeMatcher( matcher );
}
return matcher;
}
case 'html':
return html( sourceConfig.selector, sourceConfig.multiline );
case 'text':
return text( sourceConfig.selector );
case 'rich-text':
return richText(
sourceConfig.selector,
sourceConfig.__unstablePreserveWhiteSpace
);
case 'children':
return children( sourceConfig.selector );
case 'node':
return node( sourceConfig.selector );
case 'query':
const subMatchers = Object.fromEntries(
Object.entries( sourceConfig.query! ).map(
( [ key, subSourceConfig ] ) => [
key,
matcherFromSource( subSourceConfig ),
]
)
);
return query( sourceConfig.selector, subMatchers );
case 'tag': {
const matcher = prop( sourceConfig.selector, 'nodeName' );
return ( domNode: Node ) =>
( matcher( domNode ) as string )?.toLowerCase();
}
default:
// eslint-disable-next-line no-console
console.error(
`Unknown source type "${ sourceConfig.source }"`
);
return undefined;
}
}
);
/**
* Parse a HTML string into DOM tree.
*
* @param innerHTML HTML string or already parsed DOM node.
*
* @return Parsed DOM node.
*/
function parseHtml( innerHTML: string | Node ): Node {
return hpqParse( innerHTML, ( h: Node ) => h );
}
/**
* Given a block's raw content and an attribute's schema returns the attribute's
* value depending on its source.
*
* @param innerHTML Block's raw content.
* @param attributeSchema Attribute's schema.
*
* @return Attribute value.
*/
export function parseWithAttributeSchema(
innerHTML: string | Node,
attributeSchema: BlockAttribute
): unknown {
return matcherFromSource( attributeSchema )!(
parseHtml( innerHTML ) as Element
);
}
/**
* Returns the block attributes of a registered block node given its type.
*
* @param blockTypeOrName Block type or name.
* @param innerHTML Raw block content.
* @param attributes Known block attributes (from delimiters).
*
* @return All block attributes.
*/
export function getBlockAttributes(
blockTypeOrName: string | BlockType,
innerHTML: string | Node,
attributes: Record< string, unknown > = {}
): Record< string, unknown > {
const doc = parseHtml( innerHTML );
const blockType = normalizeBlockType( blockTypeOrName );
const blockAttributes = Object.fromEntries(
Object.entries( blockType?.attributes ?? {} ).map(
( [ key, schema ] ) => [
key,
getBlockAttribute( key, schema, doc, attributes, innerHTML ),
]
)
);
return applyFilters(
'blocks.getBlockAttributes',
blockAttributes,
blockType,
innerHTML,
attributes
) as Record< string, unknown >;
}