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