From ea7caf0d92ffde36da53fdd6a607d2ac0856088d Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 14 Sep 2022 23:06:38 +0200 Subject: [PATCH 1/5] Expose `addHook` from `toVdom` --- src/gutenberg-packages/hydration.js | 4 +- src/gutenberg-packages/to-vdom.js | 58 ++++++++++++++--------------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/gutenberg-packages/hydration.js b/src/gutenberg-packages/hydration.js index b0f26a1d..1dbf7ca6 100644 --- a/src/gutenberg-packages/hydration.js +++ b/src/gutenberg-packages/hydration.js @@ -1,6 +1,6 @@ import { hydrate, createElement } from 'preact/compat'; import { createGlobal } from './utils'; -import toVdom from './to-vdom'; +import toVdom, { addHook } from './to-vdom'; import visitor from './visitor'; const blockViews = createGlobal('blockViews', new Map()); @@ -11,6 +11,8 @@ const components = Object.fromEntries( visitor.map = components; +addHook('wp-blocks', visitor); + const dom = document.querySelector('.wp-site-blocks'); const vdom = toVdom(dom, visitor, createElement).props.children; diff --git a/src/gutenberg-packages/to-vdom.js b/src/gutenberg-packages/to-vdom.js index abe86eb6..320e4c89 100644 --- a/src/gutenberg-packages/to-vdom.js +++ b/src/gutenberg-packages/to-vdom.js @@ -1,41 +1,41 @@ -export default function toVdom(node, visitor, h) { - walk.visitor = visitor; - walk.h = h; - return walk(node); +import { h } from 'preact'; + +// Callbacks to run after node -> vNode tranform. +const hooks = {}; + +// Expose function to add hooks. +export const addHook = (name, cb) => { + hooks[name] = cb; } -function walk(n) { +// 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(); + + // Get the node type. + const type = String(n.nodeName).toLowerCase(); - // Do not allow script tags (for now). - if (nodeName === 'script') return null; + if (type === 'script') return null; + + // Extract props from node attributes. + const props = {}; + for (const { name, value } of n.attributes) { + props[name] = value; + } - let out = walk.h( - nodeName, - getProps(n.attributes), - walkChildren(n.childNodes) - ); - if (walk.visitor) walk.visitor(out, n); + // Walk child nodes and return vDOM children. + const children = [].map.call(n.childNodes, toVdom).filter(exists) - return out; -} + // Create vNode. + const vNode = h( type, props, children ); -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]; - props[name] = value; + // Run toVdom hooks, passing node and vNode. + for (const name in hooks) { + hooks[name](vNode, n); } - return props; -} -function walkChildren(children) { - let c = children && Array.prototype.map.call(children, walk).filter(exists); - return c && c.length ? c : null; + return vNode; } -let exists = (x) => x; +const exists = (x) => x; From 258694fa6cca370a0ec47f61dc1d11672e997b2b Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 15 Sep 2022 09:35:48 +0200 Subject: [PATCH 2/5] Refactor `toVdom` and prepare it for directives --- src/gutenberg-packages/to-vdom.js | 69 +++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/src/gutenberg-packages/to-vdom.js b/src/gutenberg-packages/to-vdom.js index 320e4c89..2882125d 100644 --- a/src/gutenberg-packages/to-vdom.js +++ b/src/gutenberg-packages/to-vdom.js @@ -1,4 +1,5 @@ import { h } from 'preact'; +import { matcherFromSource } from './utils'; // Callbacks to run after node -> vNode tranform. const hooks = {}; @@ -8,11 +9,17 @@ export const addHook = (name, cb) => { hooks[name] = cb; } +// Prefix used by WP directives. +const prefix = 'data-wp-block-'; + +// 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; - + // Get the node type. const type = String(n.nodeName).toLowerCase(); @@ -20,22 +27,66 @@ export default function toVdom(n) { // 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; } - // Walk child nodes and return vDOM children. - const children = [].map.call(n.childNodes, toVdom).filter(exists) + // Include wpBlock prop if needed. + if (Object.keys(wpBlock).length) { + props.wpBlock = wpBlock; + } + + // Find and get sourced attributes. + handleSourcedAttributes(props, n); - // Create vNode. - const vNode = h( type, props, children ); + // Walk child nodes and return vDOM children. + const children = [].map.call(n.childNodes, toVdom).filter(exists); - // Run toVdom hooks, passing node and vNode. - for (const name in hooks) { - hooks[name](vNode, n); + // Add inner blocks. + if (type === 'wp-block' && innerBlocksFound) { + wpBlock.innerBlocks = innerBlocksFound; + 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. + innerBlocksFound = vNode; + + // TODO: remove this call and use directives (Option Hooks). + for (const name in hooks) hooks[name](vNode, n); return vNode; } -const exists = (x) => x; +// 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; + } +}; + +const getWpBlockPropName = (name) => + name + .replace(prefix, '') + .replace(/-(.)/g, (_, initial) => initial.toUpperCase()); + +const exists = (x) => x; \ No newline at end of file From 1886ab29d08a0c249ed8ee154365905fb6a5f327 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 15 Sep 2022 10:20:32 +0200 Subject: [PATCH 3/5] Create directive for block components --- src/gutenberg-packages/directives.js | 36 ++++++++++ src/gutenberg-packages/hydration.js | 28 +++++--- src/gutenberg-packages/to-vdom.js | 63 +++++++++++------ src/gutenberg-packages/visitor.js | 100 --------------------------- 4 files changed, 98 insertions(+), 129 deletions(-) create mode 100644 src/gutenberg-packages/directives.js delete mode 100644 src/gutenberg-packages/visitor.js 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 1dbf7ca6..e6e2b225 100644 --- a/src/gutenberg-packages/hydration.js +++ b/src/gutenberg-packages/hydration.js @@ -1,19 +1,31 @@ import { hydrate, createElement } from 'preact/compat'; import { createGlobal } from './utils'; -import toVdom, { addHook } from './to-vdom'; -import visitor from './visitor'; +import toVdom from './to-vdom'; +import { directive } from './directives'; const blockViews = createGlobal('blockViews', new Map()); -const components = Object.fromEntries( - [...blockViews.entries()].map(([k, v]) => [k, v.Component]) -); +// Handle block components. +directive('type', (props) => { + const { + type, + attributes, + context = {}, + props: blockProps, + innerBlocks: children, + } = props.wpBlock; -visitor.map = components; + // Do nothing if there's no component for this block. + if (!blockViews.has(type)) return; -addHook('wp-blocks', visitor); + const { Component } = blockViews.get(type); + + props.children = [ + createElement(Component, { context, attributes, blockProps, children }), + ]; +}); 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 2882125d..30655bbc 100644 --- a/src/gutenberg-packages/to-vdom.js +++ b/src/gutenberg-packages/to-vdom.js @@ -1,14 +1,6 @@ import { h } from 'preact'; import { matcherFromSource } from './utils'; -// Callbacks to run after node -> vNode tranform. -const hooks = {}; - -// Expose function to add hooks. -export const addHook = (name, cb) => { - hooks[name] = cb; -} - // Prefix used by WP directives. const prefix = 'data-wp-block-'; @@ -45,10 +37,11 @@ export default function toVdom(n) { // Include wpBlock prop if needed. if (Object.keys(wpBlock).length) { props.wpBlock = wpBlock; - } - // Find and get sourced attributes. - handleSourcedAttributes(props, n); + // 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); @@ -56,21 +49,25 @@ export default function toVdom(n) { // 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. - innerBlocksFound = vNode; - - // TODO: remove this call and use directives (Option Hooks). - for (const name in hooks) hooks[name](vNode, n); + if (type === 'wp-inner-blocks') { + innerBlocksFound = vNode; + } return vNode; } +const getWpBlockPropName = (name) => toCamelCase(name.replace(prefix, '')); + // Get sourced attributes and place them in `attributes`. const handleSourcedAttributes = ({ wpBlock }, domNode) => { if (wpBlock && wpBlock.sourcedAttributes) { @@ -84,9 +81,33 @@ const handleSourcedAttributes = ({ wpBlock }, domNode) => { } }; -const getWpBlockPropName = (name) => - name - .replace(prefix, '') - .replace(/-(.)/g, (_, initial) => initial.toUpperCase()); +// 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 exists = (x) => x; \ No newline at end of file +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 From f1baed8bbba3e41f00fdcd6ec0331bb231ad930c Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 15 Sep 2022 10:28:03 +0200 Subject: [PATCH 4/5] Move directives to a folder --- src/directives/wp-block.js | 25 +++++++++++++++++++++++++ src/gutenberg-packages/hydration.js | 26 ++------------------------ 2 files changed, 27 insertions(+), 24 deletions(-) create mode 100644 src/directives/wp-block.js 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/hydration.js b/src/gutenberg-packages/hydration.js index e6e2b225..4118f2b4 100644 --- a/src/gutenberg-packages/hydration.js +++ b/src/gutenberg-packages/hydration.js @@ -1,29 +1,7 @@ -import { hydrate, createElement } from 'preact/compat'; -import { createGlobal } from './utils'; +import { hydrate } from 'preact/compat'; import toVdom from './to-vdom'; -import { directive } from './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 = [ - createElement(Component, { context, attributes, blockProps, children }), - ]; -}); +import '../directives/wp-block'; const dom = document.querySelector('.wp-site-blocks'); const vdom = toVdom(dom).props.children; From 35f54d3d2f63e4dc00294b4c2eea8db36af4b277 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 15 Sep 2022 11:21:37 +0200 Subject: [PATCH 5/5] Add wp-block-context directive --- src/directives/wp-block-context.js | 93 +++++++++++++++++++++++++++++ src/gutenberg-packages/hydration.js | 3 +- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/directives/wp-block-context.js 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/gutenberg-packages/hydration.js b/src/gutenberg-packages/hydration.js index 4118f2b4..de36fc82 100644 --- a/src/gutenberg-packages/hydration.js +++ b/src/gutenberg-packages/hydration.js @@ -1,7 +1,8 @@ +import 'preact/debug'; import { hydrate } from 'preact/compat'; import toVdom from './to-vdom'; - import '../directives/wp-block'; +import '../directives/wp-block-context'; const dom = document.querySelector('.wp-site-blocks'); const vdom = toVdom(dom).props.children;