diff --git a/src/directives/wp-block-context.js b/src/directives/wp-block-context.js new file mode 100644 index 00000000..d0d2fa5d --- /dev/null +++ b/src/directives/wp-block-context.js @@ -0,0 +1,93 @@ +import { + createContext, + useContext, + useMemo, + createElement as h, +} from 'preact/compat'; +import { directive } from '../gutenberg-packages/directives'; + +// Single context reused by all providers. +const blockContexts = createContext({}); +blockContexts.displayName = 'blockContexts'; + +/** + * Wrapper that provides the specified attributes. If there are ancestor that are also providers, it + * merges all context together. + * + * @param {*} props Component props. + * @param {*} props.provides Map of context names and block attributes. + * @param {*} props.attributes Block attributes. + * + * @returns Block context provider. + */ +const BlockContextProvider = ({ provides, attributes, children }) => { + // Get previous context. + const allContexts = useContext(blockContexts); + + // Get provided context from attributes. + const context = {}; + for (const key in provides) { + context[key] = attributes[provides[key]]; + } + + // Provide merged contexts. + return ( + + {children} + + ); +}; + +/** + * HOC that injects only the required attributes from `blockContexts` value. + * + * @param {*} Comp Component. + * @param {*} options Options. + * @param {*} options.uses Array of required contexts. + * + * @returns HOC function. + */ +const withBlockContext = (Comp, { uses }) => { + const hoc = (props) => { + const allContexts = useContext(blockContexts); + + // Memoize only those attributes that are needed. + const context = useMemo( + () => + uses.reduce((acc, attribute) => { + acc[attribute] = allContexts[attribute]; + return acc; + }, {}), + uses.map((attribute) => allContexts[attribute]) + ); + + // Inject context. + return ; + }; + + hoc.displayName = 'withBlockContext'; + return hoc; +}; + +directive('providesBlockContext', (props) => { + const { providesBlockContext: provides, attributes } = props.wpBlock; + const [block] = props.children; + + // The property `provides` can be null... + if (!provides || !Object.keys(provides).length) return; + + block.props.children = h( + BlockContextProvider, + { provides, attributes }, + block.props.children + ); +}); + +directive('usesBlockContext', (props) => { + const { usesBlockContext: uses } = props.wpBlock; + + if (!uses.length) return; + + const [block] = props.children; + block.type = withBlockContext(block.type, { uses }); +}); diff --git a/src/directives/wp-block.js b/src/directives/wp-block.js new file mode 100644 index 00000000..66b9cac5 --- /dev/null +++ b/src/directives/wp-block.js @@ -0,0 +1,25 @@ +import { createElement as h } from 'preact/compat'; +import { createGlobal } from '../gutenberg-packages/utils'; +import { directive } from '../gutenberg-packages/directives'; + +const blockViews = createGlobal('blockViews', new Map()); + +// Handle block components. +directive('type', (props) => { + const { + type, + attributes, + context = {}, + props: blockProps, + innerBlocks: children, + } = props.wpBlock; + + // Do nothing if there's no component for this block. + if (!blockViews.has(type)) return; + + const { Component } = blockViews.get(type); + + props.children = [ + h(Component, { context, attributes, blockProps, children }), + ]; +}); diff --git a/src/gutenberg-packages/directives.js b/src/gutenberg-packages/directives.js new file mode 100644 index 00000000..3fc5eaa5 --- /dev/null +++ b/src/gutenberg-packages/directives.js @@ -0,0 +1,36 @@ +import { h, options } from 'preact'; + +// WordPress Directives. +const directives = {}; + +// Expose function to add directives. +export const directive = (name, cb) => { + directives[name] = cb; +}; + +const WpDirective = (props) => { + for (const d in props.wpBlock) { + directives[d]?.(props); + } + props._wrapped = true; + const { wp, tag, children, ...rest } = props; + return h(tag, rest, children); +}; + +const old = options.vnode; + +options.vnode = (vnode) => { + const wpBlock = vnode.props.wpBlock; + const wrapped = vnode.props._wrapped; + + if (wpBlock) { + if (!wrapped) { + vnode.props.tag = vnode.type; + vnode.type = WpDirective; + } + } else if (wrapped) { + delete vnode.props._wrapped; + } + + if (old) old(vnode); +}; diff --git a/src/gutenberg-packages/hydration.js b/src/gutenberg-packages/hydration.js index b0f26a1d..de36fc82 100644 --- a/src/gutenberg-packages/hydration.js +++ b/src/gutenberg-packages/hydration.js @@ -1,17 +1,10 @@ -import { hydrate, createElement } from 'preact/compat'; -import { createGlobal } from './utils'; +import 'preact/debug'; +import { hydrate } from 'preact/compat'; import toVdom from './to-vdom'; -import visitor from './visitor'; - -const blockViews = createGlobal('blockViews', new Map()); - -const components = Object.fromEntries( - [...blockViews.entries()].map(([k, v]) => [k, v.Component]) -); - -visitor.map = components; +import '../directives/wp-block'; +import '../directives/wp-block-context'; const dom = document.querySelector('.wp-site-blocks'); -const vdom = toVdom(dom, visitor, createElement).props.children; +const vdom = toVdom(dom).props.children; setTimeout(() => console.log('hydrated', hydrate(vdom, dom)), 3000); diff --git a/src/gutenberg-packages/to-vdom.js b/src/gutenberg-packages/to-vdom.js index abe86eb6..30655bbc 100644 --- a/src/gutenberg-packages/to-vdom.js +++ b/src/gutenberg-packages/to-vdom.js @@ -1,41 +1,113 @@ -export default function toVdom(node, visitor, h) { - walk.visitor = visitor; - walk.h = h; - return walk(node); -} +import { h } from 'preact'; +import { matcherFromSource } from './utils'; + +// Prefix used by WP directives. +const prefix = 'data-wp-block-'; -function walk(n) { +// Reference to the last wrapper found. +let innerBlocksFound = null; + +// Recursive function that transfoms a DOM tree into vDOM. +export default function toVdom(n) { if (n.nodeType === 3) return n.data; if (n.nodeType !== 1) return null; - let nodeName = String(n.nodeName).toLowerCase(); - // Do not allow script tags (for now). - if (nodeName === 'script') return null; + // Get the node type. + const type = String(n.nodeName).toLowerCase(); - let out = walk.h( - nodeName, - getProps(n.attributes), - walkChildren(n.childNodes) - ); - if (walk.visitor) walk.visitor(out, n); + if (type === 'script') return null; - return out; -} - -function getProps(attrs) { - let len = attrs && attrs.length; - if (!len) return null; - let props = {}; - for (let i = 0; i < len; i++) { - let { name, value } = attrs[i]; + // Extract props from node attributes. + const props = {}; + const wpBlock = {}; + for (const { name, value } of n.attributes) { + // Store block directives in `wpBlock`. + if (name.startsWith(prefix)) { + const propName = getWpBlockPropName(name); + try { + wpBlock[propName] = JSON.parse(value); + } catch (e) { + wpBlock[propName] = value; + } + } + // Add the original property, and the rest of them. props[name] = value; } - return props; -} -function walkChildren(children) { - let c = children && Array.prototype.map.call(children, walk).filter(exists); - return c && c.length ? c : null; + // Include wpBlock prop if needed. + if (Object.keys(wpBlock).length) { + props.wpBlock = wpBlock; + + // Handle special cases with wpBlock props. + handleSourcedAttributes(props, n); + handleBlockProps(props); + } + + // Walk child nodes and return vDOM children. + const children = [].map.call(n.childNodes, toVdom).filter(exists); + + // Add inner blocks. + if (type === 'wp-block' && innerBlocksFound) { + wpBlock.innerBlocks = innerBlocksFound; + innerBlocksFound = null; + + // Set wpBlock prop again, just in case it's missing. + props.wpBlock = wpBlock; + } + + // Create vNode. Note that all `wpBlock` props should exist now to make directives work. + const vNode = h(type, props, children); + + // Save a renference to this vNode if it's an ` wrapper. + if (type === 'wp-inner-blocks') { + innerBlocksFound = vNode; + } + + return vNode; } -let exists = (x) => x; +const getWpBlockPropName = (name) => toCamelCase(name.replace(prefix, '')); + +// Get sourced attributes and place them in `attributes`. +const handleSourcedAttributes = ({ wpBlock }, domNode) => { + if (wpBlock && wpBlock.sourcedAttributes) { + const { sourcedAttributes, attributes = {} } = wpBlock; + for (const attr in sourcedAttributes) { + attributes[attr] = matcherFromSource(sourcedAttributes[attr])( + domNode + ); + } + wpBlock.attributes = attributes; + } +}; + +// Adapt block props to React/Preact format. +const handleBlockProps = ({ wpBlock }) => { + if (!wpBlock.props) return; + + const { class: className, style } = wpBlock.props; + wpBlock.props = { className, style: cssObject(style) }; +}; + +// Return an object of camelCased CSS properties. +const cssObject = (cssText) => { + if (!cssText) return {}; + + const el = document.createElement('div'); + const { style } = el; + style.cssText = cssText; + + const output = {}; + for (let i = 0; i < style.length; i += 1) { + const key = style.item(0); + output[toCamelCase(key)] = style.getPropertyValue(key); + } + + el.remove(); + return output; +}; + +const exists = (x) => x; + +const toCamelCase = (str) => + str.replace(/-(.)/g, (_, initial) => initial.toUpperCase()); diff --git a/src/gutenberg-packages/visitor.js b/src/gutenberg-packages/visitor.js deleted file mode 100644 index ebe737d9..00000000 --- a/src/gutenberg-packages/visitor.js +++ /dev/null @@ -1,100 +0,0 @@ -import { h } from "preact"; -import { matcherFromSource } from './utils'; - -export default function visitor(vNode, domNode) { - const name = (vNode.type || '').toLowerCase(); - const map = visitor.map; - - if (name === 'wp-block' && map) { - processWpBlock({ vNode, domNode, map }); - } else { - vNode.type = name.replace(/[^a-z0-9-]/i, ''); - } -} - -function processWpBlock({ vNode, domNode, map }) { - const blockType = vNode.props['data-wp-block-type']; - const Component = map[blockType]; - - if (!Component) return vNode; - - const block = h(Component, { - attributes: getAttributes(vNode, domNode), - context: {}, - blockProps: getBlockProps(vNode), - children: getChildren(vNode), - }); - - vNode.props = { - ...vNode.props, - children: [block] - }; -} - -function getBlockProps(vNode) { - const { class: className, style } = JSON.parse( - vNode.props['data-wp-block-props'] - ); - return { className, style: getStyleProp(style) }; -} - -function getAttributes(vNode, domNode) { - // Get the block attributes. - const attributes = JSON.parse( - vNode.props['data-wp-block-attributes'] - ); - - // Add the sourced attributes to the attributes object. - const sourcedAttributes = JSON.parse( - vNode.props['data-wp-block-sourced-attributes'] - ); - for (const attr in sourcedAttributes) { - attributes[attr] = matcherFromSource(sourcedAttributes[attr])( - domNode - ); - } - - return attributes; -} - -function getChildren(vNode) { - return getChildrenFromWrapper(vNode.props.children) || vNode.props.children; -} - -function getChildrenFromWrapper(children) { - if (!children?.length) return null; - - for (const child of children) { - if (isChildrenWrapper(child)) return [child] || []; - } - - // Try with the next nesting level. - return getChildrenFromWrapper( - [].concat(...children.map((child) => child?.props?.children || [])) - ); -} - -function isChildrenWrapper(vNode) { - return vNode.type === 'wp-inner-blocks'; -} - -function toCamelCase(name) { - return name.replace(/-(.)/g, (match, letter) => letter.toUpperCase()); -} - -export function getStyleProp(cssText) { - if (!cssText) return {}; - - const el = document.createElement('div'); - const { style } = el; - style.cssText = cssText; - - const output = {}; - for (let i = 0; i < style.length; i += 1) { - const key = style.item(0); - output[toCamelCase(key)] = style.getPropertyValue(key); - } - - el.remove(); - return output; -} \ No newline at end of file