Skip to content
This repository has been archived by the owner on Jul 28, 2023. It is now read-only.

⚛️ Hydrate the blocks with Directives Hydration (using wp-block and wp-inner-blocks) #66

Merged
merged 20 commits into from
Sep 13, 2022
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions block-hydration-experiments.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
*/
function block_hydration_experiments_init()
{
wp_enqueue_script('vendors', plugin_dir_url(__FILE__) . 'build/vendors.js');

wp_register_script(
'hydration',
plugin_dir_url(__FILE__) . 'build/gutenberg-packages/hydration.js',
Expand Down
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
},
"dependencies": {
"hpq": "^1.3.0",
"preact": "^10.10.6"
"preact": "^10.10.6",
"preact-markup": "^2.1.1"
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved
}
}
2 changes: 2 additions & 0 deletions src/blocks/interactive-child/register-view.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'preact/debug';
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved

import registerBlockView from '../../gutenberg-packages/register-block-view';
import View from './view';

Expand Down
8 changes: 6 additions & 2 deletions src/blocks/interactive-child/view.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import CounterContext from '../../context/counter';
import ThemeContext from '../../context/theme';
import { useContext } from '../../gutenberg-packages/wordpress-element';

const View = ({ blockProps, context }) => {
const theme = 'cool theme';
const counter = 0;
const theme = useContext(ThemeContext);
const counter = useContext(CounterContext);

return (
<div {...blockProps}>
Expand Down
20 changes: 11 additions & 9 deletions src/blocks/interactive-parent/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,22 @@
// the site.
import '@wordpress/block-editor';

import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
import Button from './shared/button';
import Title from './shared/title';
import { InnerBlocks, useBlockProps, RichText } from '@wordpress/block-editor';

const Edit = ({ attributes: { counter, title, secret }, setAttributes }) => (
const Edit = ({
attributes: { counter = 0, title, secret },
setAttributes,
}) => (
<>
<div {...useBlockProps()}>
<Title
<RichText
tagName="h2"
className="title"
value={title}
onChange={(val) => setAttributes({ title: val })}
placeholder="This will be passed through context to child blocks"
>
{title}
</Title>
<Button>Show</Button>
/>
<button>Show</button>
<button onClick={() => setAttributes({ counter: counter + 1 })}>
{counter}
</button>
Expand Down
2 changes: 2 additions & 0 deletions src/blocks/interactive-parent/register-view.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'preact/debug';
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved

import registerBlockView from '../../gutenberg-packages/register-block-view';
import View from './view';

Expand Down
5 changes: 0 additions & 5 deletions src/blocks/interactive-parent/shared/button.js

This file was deleted.

7 changes: 0 additions & 7 deletions src/blocks/interactive-parent/shared/title.js

This file was deleted.

15 changes: 6 additions & 9 deletions src/blocks/interactive-parent/view.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { createContext, useState } from 'preact/compat';
import Button from './shared/button';
import Title from './shared/title';

const Counter = createContext(null);
const Theme = createContext(null);
import Counter from '../../context/counter';
import Theme from '../../context/theme';
import { useState } from '../../gutenberg-packages/wordpress-element';

const View = ({
blockProps: {
Expand All @@ -27,9 +24,9 @@ const View = ({
fontWeight: bold ? 900 : fontWeight,
}}
>
<Title>{title}</Title>
<Button handler={() => setShow(!show)}>Show</Button>
<Button handler={() => setBold(!bold)}>Bold</Button>
<h2 className="title">{title}</h2>
<button onClick={() => setShow(!show)}>Show</button>
<button onClick={() => setBold(!bold)}>Bold</button>
<button onClick={() => setCounter(counter + 1)}>
{counter}
</button>
Expand Down
7 changes: 2 additions & 5 deletions src/blocks/non-interactive-parent/edit.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
import { RichText } from '../../gutenberg-packages/wordpress-blockeditor';
import { InnerBlocks, useBlockProps, RichText } from '@wordpress/block-editor';

const Edit = ({ attributes, setAttributes }) => (
<div {...useBlockProps()}>
Expand All @@ -9,9 +8,7 @@ const Edit = ({ attributes, setAttributes }) => (
onChange={(val) => setAttributes({ title: val })}
placeholder="This will be passed through context to child blocks"
value={attributes.title}
>
{attributes.title}
</RichText>
/>
<InnerBlocks />
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/blocks/non-interactive-parent/view.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const View = ({ attributes, blockProps, children }) => (
<div {...blockProps}>
<p className="title">{attributes.title}</p>
<h4 className="title">{attributes.title}</h4>
{children}
</div>
);
Expand Down
14 changes: 9 additions & 5 deletions src/context/counter.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { createContext } from '@wordpress/element';
import { createContext } from 'preact/compat';

if (typeof window.reactContext === 'undefined') {
window.reactContext = createContext(null);
if (typeof window.counterContext === 'undefined') {
window.counterContext = window.wp.element
? window.wp.element.createContext(null)
: createContext(null);

window.counterContext.displayName = 'CounterContext';
}
window.reactContext.displayName = 'CounterContext';
export default window.reactContext;

export default window.counterContext;
14 changes: 9 additions & 5 deletions src/context/theme.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { createContext } from '@wordpress/element';
import { createContext } from 'preact/compat';

if (typeof window.themeReactContext === 'undefined') {
window.themeReactContext = createContext(null);
if (typeof window.themeContext === 'undefined') {
window.themeContext = window.wp.element
? window.wp.element.createContext('initial')
: createContext('initial');

window.themeContext.displayName = 'ThemeContext';
}
window.themeReactContext.displayName = 'ThemeContext';
export default window.themeReactContext;

export default window.themeContext;
13 changes: 13 additions & 0 deletions src/gutenberg-packages/hydration.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
import { hydrate, createElement } from 'preact/compat';
import { createGlobal } from './utils';
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;

const dom = document.querySelector('.wp-site-blocks');
const vdom = toVdom(dom, visitor, createElement, {}).props.children;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need .props.children here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because you run hydrate on the parent element (wp-site-blocks), but you pass the vdom of the content (wp-site-blocks works as a wrapper).

We can change this with a better approach in the future. 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see. I'm using createRootFragment for that 🙂

hydrate(vdom, dom);
68 changes: 68 additions & 0 deletions src/gutenberg-packages/to-vdom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
const EMPTY_OBJ = {};

// deeply convert an XML DOM to VDOM
export default function toVdom(node, visitor, h, options) {
walk.visitor = visitor;
walk.h = h;
walk.options = options || EMPTY_OBJ;
return walk(node);
}

function walk(n, index, arr) {
if (n.nodeType === 3) {
let text = 'textContent' in n ? n.textContent : n.nodeValue || '';
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved

if (walk.options.trim !== false) {
let isFirstOrLast = index === 0 || index === arr.length - 1;

// trim strings but don't entirely collapse whitespace
if (text.match(/^[\s\n]+$/g) && walk.options.trim !== 'all') {
text = ' ';
} else {
text = text.replace(
/(^[\s\n]+|[\s\n]+$)/g,
walk.options.trim === 'all' || isFirstOrLast ? '' : ' '
);
}
// skip leading/trailing whitespace
if ((!text || text === ' ') && arr.length > 1 && isFirstOrLast)
return null;
}
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved
return text;
}
if (n.nodeType !== 1) return null;
let nodeName = String(n.nodeName).toLowerCase();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there a possibility that a node name is not in lowercase?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the node is an Element, it always return the uppercase name (e.g. DIV for <div>). 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. I was using node.localName 🙂


// Do not allow script tags unless explicitly specified
if (nodeName === 'script' && !walk.options.allowScripts) return null;
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved

let out = walk.h(
nodeName,
getProps(n.attributes),
walkChildren(n.childNodes)
);
if (walk.visitor) walk.visitor(out, n);

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];
if (name.substring(0, 2) === 'on' && walk.options.allowEvents) {
DAreRodz marked this conversation as resolved.
Show resolved Hide resolved
value = new Function(value); // eslint-disable-line no-new-func
}
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;
}

let exists = (x) => x;
100 changes: 100 additions & 0 deletions src/gutenberg-packages/visitor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { createElement as h } from "preact/compat";
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.props?.children || [];
}

// 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;
}
Loading