diff --git a/package-lock.json b/package-lock.json
index 876887ea..d70f9c5a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2102,6 +2102,19 @@
"integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
"dev": true
},
+ "@preact/signals": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.1.2.tgz",
+ "integrity": "sha512-MLNNrICSllHBhpXBvXbl7K5L1HmIjuTzgBw+zdODqjM/cLGPXdYiAWt4lqXlrxNavYdoU4eljb+TLE+DRL+6yw==",
+ "requires": {
+ "@preact/signals-core": "^1.2.2"
+ }
+ },
+ "@preact/signals-core": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.2.2.tgz",
+ "integrity": "sha512-z3/bCj7rRA21RJb4FeJ4guCrD1CQbaURHkCTunUWQpxUMAFOPXCD8tSFqERyGrrcSb4T3Hrmdc1OAl0LXBHwiw=="
+ },
"@prettier/plugin-php": {
"version": "0.18.9",
"resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.18.9.tgz",
diff --git a/package.json b/package.json
index bafeb09c..d0c60e63 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"prettier": "^2.7.1"
},
"dependencies": {
+ "@preact/signals": "^1.1.2",
"hpq": "^1.3.0",
"preact": "^10.10.6"
}
diff --git a/src/runtime/components.js b/src/runtime/components.js
new file mode 100644
index 00000000..8edcf5f4
--- /dev/null
+++ b/src/runtime/components.js
@@ -0,0 +1,11 @@
+import { useMemo } from 'preact/hooks';
+import { deepSignal } from './deepsignal';
+import { component } from './hooks';
+
+export default () => {
+ const WpContext = ({ children, data, context: { Provider } }) => {
+ const signals = useMemo(() => deepSignal(JSON.parse(data)), []);
+ return {children};
+ };
+ component('wp-context', WpContext);
+};
diff --git a/src/runtime/deepsignal.js b/src/runtime/deepsignal.js
new file mode 100644
index 00000000..84901785
--- /dev/null
+++ b/src/runtime/deepsignal.js
@@ -0,0 +1,42 @@
+import { signal } from '@preact/signals';
+import { knownSymbols, shouldWrap } from './utils';
+
+const proxyToSignals = new WeakMap();
+const objToProxy = new WeakMap();
+const returnSignal = /^\$/;
+
+export const deepSignal = (obj) => new Proxy(obj, handlers);
+
+const handlers = {
+ get(target, prop, receiver) {
+ if (typeof prop === 'symbol' && knownSymbols.has(prop))
+ return Reflect.get(target, prop, receiver);
+ const shouldReturnSignal = returnSignal.test(prop);
+ const key = shouldReturnSignal ? prop.replace(returnSignal, '') : prop;
+ if (!proxyToSignals.has(receiver))
+ proxyToSignals.set(receiver, new Map());
+ const signals = proxyToSignals.get(receiver);
+ if (!signals.has(key)) {
+ let val = Reflect.get(target, key, receiver);
+ if (typeof val === 'object' && val !== null && shouldWrap(val))
+ val = new Proxy(val, handlers);
+ signals.set(key, signal(val));
+ }
+ return returnSignal ? signals.get(key) : signals.get(key).value;
+ },
+
+ set(target, prop, val, receiver) {
+ let internal = val;
+ if (typeof val === 'object' && val !== null && shouldWrap(val)) {
+ if (!objToProxy.has(val))
+ objToProxy.set(val, new Proxy(val, handlers));
+ internal = objToProxy.get(val);
+ }
+ if (!proxyToSignals.has(receiver))
+ proxyToSignals.set(receiver, new Map());
+ const signals = proxyToSignals.get(receiver);
+ if (!signals.has(prop)) signals.set(prop, signal(internal));
+ else signals.get(prop).value = internal;
+ return Reflect.set(target, prop, val, receiver);
+ },
+};
diff --git a/src/runtime/directives.js b/src/runtime/directives.js
new file mode 100644
index 00000000..3e7a42cd
--- /dev/null
+++ b/src/runtime/directives.js
@@ -0,0 +1,84 @@
+import { useContext, useMemo } from 'preact/hooks';
+import { useSignalEffect } from '@preact/signals';
+import { directive } from './hooks';
+import { deepSignal } from './deepsignal';
+import { getCallback } from './utils';
+
+const raf = window.requestAnimationFrame;
+// Until useSignalEffects is fixed: https://github.com/preactjs/signals/issues/228
+const tick = () => new Promise((r) => raf(() => raf(r)));
+
+export default () => {
+ // wp-context
+ directive(
+ 'context',
+ ({
+ directives: { context },
+ props: { children },
+ context: { Provider },
+ }) => {
+ const signals = useMemo(() => deepSignal(context.default), []);
+ return {children};
+ }
+ );
+
+ // wp-effect
+ directive(
+ 'effect',
+ ({ directives: { effect }, element, context: mainContext }) => {
+ const context = useContext(mainContext);
+ Object.values(effect).forEach((callback) => {
+ useSignalEffect(() => {
+ const cb = getCallback(callback);
+ cb({ context, tick, ref: element.ref.current });
+ });
+ });
+ }
+ );
+
+ // wp-on:[event]
+ directive('on', ({ directives: { on }, element, context: mainContext }) => {
+ const context = useContext(mainContext);
+ Object.entries(on).forEach(([name, callback]) => {
+ element.props[`on${name}`] = (event) => {
+ const cb = getCallback(callback);
+ cb({ context, event });
+ };
+ });
+ });
+
+ // wp-class:[classname]
+ directive(
+ 'class',
+ ({
+ directives: { class: className },
+ element,
+ context: mainContext,
+ }) => {
+ const context = useContext(mainContext);
+ Object.keys(className)
+ .filter((n) => n !== 'default')
+ .forEach((name) => {
+ const cb = getCallback(className[name]);
+ const result = cb({ context });
+ if (!result) element.props.class.replace(name, '');
+ else if (!element.props.class.includes(name))
+ element.props.class += ` ${name}`;
+ });
+ }
+ );
+
+ // wp-bind:[attribute]
+ directive(
+ 'bind',
+ ({ directives: { bind }, element, context: mainContext }) => {
+ const context = useContext(mainContext);
+ Object.entries(bind)
+ .filter((n) => n !== 'default')
+ .forEach(([attribute, callback]) => {
+ const cb = getCallback(callback);
+ element.props[attribute] = cb({ context });
+ });
+ }
+ );
+};
diff --git a/src/runtime/hooks.js b/src/runtime/hooks.js
new file mode 100644
index 00000000..46e4012d
--- /dev/null
+++ b/src/runtime/hooks.js
@@ -0,0 +1,57 @@
+import { h, options, createContext } from 'preact';
+import { useRef } from 'preact/hooks';
+
+// Main context
+const context = createContext({});
+
+// WordPress Directives.
+const directives = {};
+export const directive = (name, cb) => {
+ directives[name] = cb;
+};
+
+// WordPress Components.
+const components = {};
+export const component = (name, Comp) => {
+ components[name] = Comp;
+};
+
+// Directive wrapper.
+const WpDirective = ({ type, wp, props: originalProps }) => {
+ const ref = useRef(null);
+ const element = h(type, { ...originalProps, ref, _wrapped: true });
+ const props = { ...originalProps, children: element };
+ const directiveArgs = { directives: wp, props, element, context };
+
+ for (const d in wp) {
+ const wrapper = directives[d]?.(directiveArgs);
+ if (wrapper !== undefined) props.children = wrapper;
+ }
+
+ return props.children;
+};
+
+// Preact Options Hook called each time a vnode is created.
+const old = options.vnode;
+options.vnode = (vnode) => {
+ const type = vnode.type;
+ const wp = vnode.props.wp;
+
+ if (typeof type === 'string' && type.startsWith('wp-')) {
+ vnode.type = components[type];
+ vnode.props.context = context;
+ }
+
+ if (wp) {
+ const props = vnode.props;
+ delete props.wp;
+ if (!props._wrapped) {
+ vnode.props = { type: vnode.type, wp, props };
+ vnode.type = WpDirective;
+ } else {
+ delete props._wrapped;
+ }
+ }
+
+ if (old) old(vnode);
+};
diff --git a/src/runtime/index.js b/src/runtime/index.js
index 2bbdee8d..b72f82db 100644
--- a/src/runtime/index.js
+++ b/src/runtime/index.js
@@ -1 +1,24 @@
-console.log('Runtime loaded correctly.');
+import { hydrate } from 'preact';
+import registerDirectives from './directives';
+import registerComponents from './components';
+import toVdom from './vdom';
+import { createRootFragment } from './utils';
+
+/**
+ * Initialize the initial vDOM.
+ */
+document.addEventListener('DOMContentLoaded', async () => {
+ registerDirectives();
+ registerComponents();
+
+ // Create the root fragment to hydrate everything.
+ const rootFragment = createRootFragment(
+ document.documentElement,
+ document.body
+ );
+
+ const vdom = toVdom(document.body);
+ hydrate(vdom, rootFragment);
+
+ console.log('hydrated!');
+});
diff --git a/src/runtime/utils.js b/src/runtime/utils.js
new file mode 100644
index 00000000..87b940cb
--- /dev/null
+++ b/src/runtime/utils.js
@@ -0,0 +1,74 @@
+// For wrapperless hydration of document.body.
+// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c
+export const createRootFragment = (parent, replaceNode) => {
+ replaceNode = [].concat(replaceNode);
+ const s = replaceNode[replaceNode.length - 1].nextSibling;
+ function insert(c, r) {
+ parent.insertBefore(c, r || s);
+ }
+ return (parent.__k = {
+ nodeType: 1,
+ parentNode: parent,
+ firstChild: replaceNode[0],
+ childNodes: replaceNode,
+ insertBefore: insert,
+ appendChild: insert,
+ removeChild(c) {
+ parent.removeChild(c);
+ },
+ });
+};
+
+// Helper function to await until the CPU is idle.
+export const idle = () =>
+ new Promise((resolve) => window.requestIdleCallback(resolve));
+
+export const knownSymbols = new Set(
+ Object.getOwnPropertyNames(Symbol)
+ .map((key) => Symbol[key])
+ .filter((value) => typeof value === 'symbol')
+);
+const supported = new Set([
+ Object,
+ Array,
+ Int8Array,
+ Uint8Array,
+ Uint8ClampedArray,
+ Int16Array,
+ Uint16Array,
+ Int32Array,
+ Uint32Array,
+ Float32Array,
+ Float64Array,
+]);
+export const shouldWrap = ({ constructor }) => {
+ const isBuiltIn =
+ typeof constructor === 'function' &&
+ constructor.name in globalThis &&
+ globalThis[constructor.name] === constructor;
+ return !isBuiltIn || supported.has(constructor);
+};
+
+// Deep Merge
+const isObject = (item) =>
+ item && typeof item === 'object' && !Array.isArray(item);
+
+export const deepMerge = (target, source) => {
+ if (isObject(target) && isObject(source)) {
+ for (const key in source) {
+ if (isObject(source[key])) {
+ if (!target[key]) Object.assign(target, { [key]: {} });
+ deepMerge(target[key], source[key]);
+ } else {
+ Object.assign(target, { [key]: source[key] });
+ }
+ }
+ }
+};
+
+// Get callback.
+export const getCallback = (path) => {
+ let current = window.wpx;
+ path.split('.').forEach((p) => (current = current[p]));
+ return current;
+};
diff --git a/src/runtime/vdom.js b/src/runtime/vdom.js
new file mode 100644
index 00000000..6c3b0228
--- /dev/null
+++ b/src/runtime/vdom.js
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import { h } from 'preact';
+
+// Recursive function that transfoms a DOM tree into vDOM.
+export default function toVdom(node) {
+ const props = {};
+ const attributes = node.attributes;
+ const wpDirectives = {};
+ let hasWpDirectives = false;
+
+ if (node.nodeType === 3) return node.data;
+ if (node.nodeType === 8) return null;
+ if (node.localName === 'script') return h('script');
+
+ for (let i = 0; i < attributes.length; i++) {
+ const name = attributes[i].name;
+ if (name.startsWith('wp-')) {
+ hasWpDirectives = true;
+ let val = attributes[i].value;
+ try {
+ val = JSON.parse(val);
+ } catch (e) {}
+ const [, prefix, suffix] = /wp-([^:]+):?(.*)$/.exec(name);
+ wpDirectives[prefix] = wpDirectives[prefix] || {};
+ wpDirectives[prefix][suffix || 'default'] = val;
+ } else {
+ props[name] = attributes[i].value;
+ }
+ }
+
+ if (hasWpDirectives) props.wp = wpDirectives;
+
+ // Walk child nodes and return vDOM children.
+ const children = [].map.call(node.childNodes, toVdom).filter(exists);
+
+ return h(node.localName, props, children);
+}
+
+// Filter existing items.
+const exists = (x) => x;
diff --git a/webpack.config.js b/webpack.config.js
index f0839132..c2070f52 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -14,9 +14,10 @@ module.exports = {
},
splitChunks: {
cacheGroups: {
- vendor: {
+ vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
+ minSize: 0,
chunks: 'all',
},
},