diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..3a05da56 --- /dev/null +++ b/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "@babel/preset-react", + { + "throwIfNamespace": false + } + ] + ] +} diff --git a/package.json b/package.json index d0c60e63..dff3a556 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "0.1.0", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", - "main": "build/index.js", "scripts": { "build": "webpack --mode=production", "start": "webpack --mode=development --watch", @@ -11,7 +10,8 @@ "test": "jest", "test:watch": "jest --watch", "plugin-zip": "wp-scripts plugin-zip", - "wp-env": "wp-env" + "wp-env": "wp-env", + "ssr": "node ssr.mjs" }, "prettier": { "useTabs": true, @@ -39,6 +39,7 @@ "dependencies": { "@preact/signals": "^1.1.2", "hpq": "^1.3.0", - "preact": "^10.10.6" + "preact": "^10.10.6", + "ultrahtml": "^0.4.0" } } diff --git a/src/blocks/favorites-number/block.json b/src/blocks/favorites-number/block.json new file mode 100644 index 00000000..2b7216a6 --- /dev/null +++ b/src/blocks/favorites-number/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "bhe/favorites-number", + "version": "0.1.0", + "title": "BHE - Favorites Number", + "category": "text", + "icon": "heart", + "description": "", + "textdomain": "bhe", + "editorScript": "file:./index.js", + "editorStyle": "file:./style.css", + "style": "file:./style-index.css", + "render": "file:./render.php" +} diff --git a/src/blocks/favorites-number/edit.js b/src/blocks/favorites-number/edit.js new file mode 100644 index 00000000..0ed27f42 --- /dev/null +++ b/src/blocks/favorites-number/edit.js @@ -0,0 +1,19 @@ +import '@wordpress/block-editor'; +import { useBlockProps } from '@wordpress/block-editor'; + +const Edit = () => { + return ( +
+ :heart: + 5 +
+ ); +}; + +export default Edit; diff --git a/src/blocks/favorites-number/index.js b/src/blocks/favorites-number/index.js new file mode 100644 index 00000000..468e578a --- /dev/null +++ b/src/blocks/favorites-number/index.js @@ -0,0 +1,8 @@ +import { registerBlockType } from '@wordpress/blocks'; +import Edit from './edit'; +import './style.scss'; + +registerBlockType('bhe/favorites-number', { + edit: Edit, + save: () => null, +}); diff --git a/src/blocks/favorites-number/render.php b/src/blocks/favorites-number/render.php new file mode 100644 index 00000000..05743133 --- /dev/null +++ b/src/blocks/favorites-number/render.php @@ -0,0 +1,13 @@ +
+ :heart: + + 0 + +
\ No newline at end of file diff --git a/src/blocks/favorites-number/style.scss b/src/blocks/favorites-number/style.scss new file mode 100644 index 00000000..cadd0f72 --- /dev/null +++ b/src/blocks/favorites-number/style.scss @@ -0,0 +1,4 @@ +.wp-block-bhe-favorites-number { + border: 1px solid rgb(209, 21, 21); + position: relative; +} \ No newline at end of file diff --git a/src/blocks/favorites-number/view.js b/src/blocks/favorites-number/view.js new file mode 100644 index 00000000..31d4f2d5 --- /dev/null +++ b/src/blocks/favorites-number/view.js @@ -0,0 +1,48 @@ +import wpx from '../../runtime/wpx'; +import { deepMerge } from '../../runtime/utils'; + +wpx({ + state: { + favorites: { + posts: [], + count: ({ state }) => state.favorites.posts.length, + }, + }, + selectors: { + favorites: { + isPostIncluded: ({ state, context: { post } }) => + `https://s.w.org/images/core/emoji/14.0.0/svg/${ + state.favorites.posts.includes(post.id) ? '2764' : '1f90d' + }.svg`, + isFavoritePostsEmpty: ({ state }) => + `https://s.w.org/images/core/emoji/14.0.0/svg/${ + state.favorites.posts.length !== 0 ? '2764' : '1f90d' + }.svg`, + }, + }, + actions: { + favorites: { + togglePost: ({ state, context }) => { + const index = state.favorites.posts.findIndex( + (post) => post === context.post.id + ); + if (index === -1) state.favorites.posts.push(context.post.id); + else state.favorites.posts.splice(index, 1); + }, + save: ({ state }) => { + localStorage.setItem( + 'wpmoviesdemo.favorites', + JSON.stringify(state.favorites) + ); + }, + restore: ({ state }) => { + deepMerge( + state.favorites, + JSON.parse( + localStorage.getItem('wpmoviesdemo.favorites') + ) || {} + ); + }, + }, + }, +}); diff --git a/src/blocks/post-favorite/block.json b/src/blocks/post-favorite/block.json new file mode 100644 index 00000000..e2c77c94 --- /dev/null +++ b/src/blocks/post-favorite/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "bhe/post-favorite", + "version": "0.1.0", + "title": "BHE - Post Favorite", + "category": "text", + "icon": "heart", + "description": "", + "textdomain": "bhe", + "editorScript": "file:./index.js", + "editorStyle": "file:./style.css", + "style": "file:./style-index.css", + "render": "file:./render.php" +} diff --git a/src/blocks/post-favorite/edit.js b/src/blocks/post-favorite/edit.js new file mode 100644 index 00000000..d555f2fc --- /dev/null +++ b/src/blocks/post-favorite/edit.js @@ -0,0 +1,18 @@ +import '@wordpress/block-editor'; +import { useBlockProps } from '@wordpress/block-editor'; + +const Edit = () => { + return ( +
+ :heart: +
+ ); +}; + +export default Edit; diff --git a/src/blocks/post-favorite/index.js b/src/blocks/post-favorite/index.js new file mode 100644 index 00000000..31831a71 --- /dev/null +++ b/src/blocks/post-favorite/index.js @@ -0,0 +1,8 @@ +import { registerBlockType } from '@wordpress/blocks'; +import Edit from './edit'; +import './style.scss'; + +registerBlockType('bhe/post-favorite', { + edit: Edit, + save: () => null, +}); diff --git a/src/blocks/post-favorite/render.php b/src/blocks/post-favorite/render.php new file mode 100644 index 00000000..97f1e788 --- /dev/null +++ b/src/blocks/post-favorite/render.php @@ -0,0 +1,15 @@ + + + + + wp-on:click="actions.favorites.togglePost" + class="emoji" + alt=":heart:" + src="https://s.w.org/images/core/emoji/14.0.0/svg/1f90d.svg" + wp-bind:src="selectors.favorites.isPostIncluded" + /> + \ No newline at end of file diff --git a/src/blocks/post-favorite/style.scss b/src/blocks/post-favorite/style.scss new file mode 100644 index 00000000..7e2c2e16 --- /dev/null +++ b/src/blocks/post-favorite/style.scss @@ -0,0 +1,4 @@ +.wp-block-bhe-post-favorite { + width: 1rem; + cursor: pointer; +} \ No newline at end of file diff --git a/src/blocks/tabs/block.json b/src/blocks/tabs/block.json new file mode 100644 index 00000000..0b3716d4 --- /dev/null +++ b/src/blocks/tabs/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "bhe/tabs", + "version": "0.1.0", + "title": "BHE - Tabs", + "category": "text", + "icon": "tab", + "description": "Tabs", + "textdomain": "bhe", + "editorScript": "file:./index.js", + "editorStyle": "file:./style.css", + "style": "file:./style-index.css", + "render": "file:./render.php" +} diff --git a/src/blocks/tabs/edit.js b/src/blocks/tabs/edit.js new file mode 100644 index 00000000..60a9952f --- /dev/null +++ b/src/blocks/tabs/edit.js @@ -0,0 +1,7 @@ +import { useBlockProps } from '@wordpress/block-editor'; + +const Edit = () => { + return
I'm a tab
; +}; + +export default Edit; diff --git a/src/blocks/tabs/index.js b/src/blocks/tabs/index.js new file mode 100644 index 00000000..2bd23955 --- /dev/null +++ b/src/blocks/tabs/index.js @@ -0,0 +1,8 @@ +import { registerBlockType } from '@wordpress/blocks'; +import Edit from './edit'; +import './style.scss'; + +registerBlockType('bhe/tabs', { + edit: Edit, + save: () => null, +}); diff --git a/src/blocks/tabs/render.php b/src/blocks/tabs/render.php new file mode 100644 index 00000000..28a22996 --- /dev/null +++ b/src/blocks/tabs/render.php @@ -0,0 +1,11 @@ + [ + 'show' => true, + 'dontShow' => false, + ], +]); ?> + +

The tabs!

+
I should be shown
+
I should not be shown
\ No newline at end of file diff --git a/src/blocks/tabs/style.scss b/src/blocks/tabs/style.scss new file mode 100644 index 00000000..e4736146 --- /dev/null +++ b/src/blocks/tabs/style.scss @@ -0,0 +1,2 @@ +.wp-block-bhe-tabs { +} diff --git a/src/runtime/components.js b/src/runtime/components.js index 8edcf5f4..a45512cf 100644 --- a/src/runtime/components.js +++ b/src/runtime/components.js @@ -4,8 +4,13 @@ import { component } from './hooks'; export default () => { const WpContext = ({ children, data, context: { Provider } }) => { - const signals = useMemo(() => deepSignal(JSON.parse(data)), []); + const signals = useMemo(() => deepSignal(JSON.parse(data)), [data]); return {children}; }; component('wp-context', WpContext); + + const WpShow = ({ children }) => { + return children; + }; + component('wp-show', WpShow); }; diff --git a/src/runtime/deepsignal.js b/src/runtime/deepsignal.js index 84901785..08742367 100644 --- a/src/runtime/deepsignal.js +++ b/src/runtime/deepsignal.js @@ -1,25 +1,27 @@ 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); +export const options = { returnSignal: /^\$/ }; 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; + const returnSignal = options.returnSignal.test(prop); + const key = returnSignal + ? prop.replace(options.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); + if (typeof val === 'object' && val !== null) { + if (!objToProxy.has(val)) + objToProxy.set(val, new Proxy(val, handlers)); + val = objToProxy.get(val); + } signals.set(key, signal(val)); } return returnSignal ? signals.get(key) : signals.get(key).value; @@ -27,7 +29,7 @@ const handlers = { set(target, prop, val, receiver) { let internal = val; - if (typeof val === 'object' && val !== null && shouldWrap(val)) { + if (typeof val === 'object' && val !== null) { if (!objToProxy.has(val)) objToProxy.set(val, new Proxy(val, handlers)); internal = objToProxy.get(val); diff --git a/src/runtime/directives.js b/src/runtime/directives.js index 5741046d..808b7aa4 100644 --- a/src/runtime/directives.js +++ b/src/runtime/directives.js @@ -21,7 +21,10 @@ export default () => { props: { children }, context: { Provider }, }) => { - const signals = useMemo(() => deepSignal(context.default), []); + const signals = useMemo( + () => deepSignal(context.default), + [JSON.stringify(context.default)] + ); return {children}; } ); @@ -34,7 +37,12 @@ export default () => { Object.values(effect).forEach((callback) => { useSignalEffect(() => { const cb = getCallback(callback); - cb({ context, tick, ref: element.ref.current }); + cb({ + context, + tick, + ref: element.ref.current, + state: window.wpx.state, + }); }); }); } @@ -46,7 +54,7 @@ export default () => { Object.entries(on).forEach(([name, callback]) => { element.props[`on${name}`] = (event) => { const cb = getCallback(callback); - cb({ context, event }); + cb({ context, event, state: window.wpx.state }); }; }); }); @@ -64,7 +72,7 @@ export default () => { .filter((n) => n !== 'default') .forEach((name) => { const cb = getCallback(className[name]); - const result = cb({ context }); + const result = cb({ context, state: window.wpx.state }); if (!result) element.props.class.replace(name, ''); else if (!element.props.class.includes(name)) element.props.class += ` ${name}`; @@ -81,7 +89,10 @@ export default () => { .filter((n) => n !== 'default') .forEach(([attribute, callback]) => { const cb = getCallback(callback); - element.props[attribute] = cb({ context }); + element.props[attribute] = cb({ + context, + state: window.wpx.state, + }); }); } ); @@ -109,18 +120,7 @@ export default () => { event.preventDefault(); // Fetch the page (or return it from cache). - await navigate(href); - - // Update the scroll, depending on the option. True by default. - if (link?.scroll === 'smooth') { - window.scrollTo({ - top: 0, - left: 0, - behavior: 'smooth', - }); - } else if (link?.scroll !== false) { - window.scrollTo(0, 0); - } + await navigate(href, link?.scroll); }; } } diff --git a/src/runtime/hooks.js b/src/runtime/hooks.js index 46e4012d..617f6009 100644 --- a/src/runtime/hooks.js +++ b/src/runtime/hooks.js @@ -38,8 +38,11 @@ options.vnode = (vnode) => { const wp = vnode.props.wp; if (typeof type === 'string' && type.startsWith('wp-')) { - vnode.type = components[type]; - vnode.props.context = context; + vnode.props.children = h( + components[type], + { ...vnode.props, context }, + vnode.props.children + ); } if (wp) { diff --git a/src/runtime/router.js b/src/runtime/router.js index 94c6f659..81ae1bcc 100644 --- a/src/runtime/router.js +++ b/src/runtime/router.js @@ -83,10 +83,10 @@ export const navigate = async (href) => { // cache. window.addEventListener('popstate', async () => { const url = cleanUrl(window.location); // Remove hash. - const page = pages.has(url) && (await pages.get(url)); - if (page) { - document.head.replaceChildren(...page.head); - render(page.body, rootFragment); + if (pages.has(url)) { + const { body, head } = await pages.get(url); + document.head.replaceChildren(...head); + render(body, rootFragment); } else { window.location.reload(); } diff --git a/src/runtime/wpx.js b/src/runtime/wpx.js new file mode 100644 index 00000000..6f7c1b47 --- /dev/null +++ b/src/runtime/wpx.js @@ -0,0 +1,10 @@ +import { deepSignal } from './deepsignal'; +import { deepMerge } from './utils'; + +const rawState = {}; +window.wpx = { state: deepSignal(rawState) }; + +export default ({ state, ...block }) => { + deepMerge(window.wpx, block); + deepMerge(rawState, state); +}; diff --git a/ssr.mjs b/ssr.mjs new file mode 100644 index 00000000..03c4062d --- /dev/null +++ b/ssr.mjs @@ -0,0 +1,31 @@ +import { transform, html } from 'ultrahtml'; +import { readFile, writeFile } from 'fs/promises'; + +const file = '/blocks/tabs/render.php'; + +const propsToArray = (props) => { + let result = '['; + Object.entries(props).forEach(([key, value]) => { + result += `["${key}", "${value}"]`; + }); + result += ']'; + return result; +}; + +const start = async () => { + const php = await readFile('./src' + file, { + encoding: 'utf8', + }); + const output = await transform(php, { + components: { + 'wp-show': (props, children) => + html` + ${children} + `, + }, + }); + await writeFile('./build' + file, output); + console.log('done!'); +}; + +start(); diff --git a/webpack.config.js b/webpack.config.js index c2070f52..a86ef4fc 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,54 +1,61 @@ +const defaultConfig = require('@wordpress/scripts/config/webpack.config'); const { resolve } = require('path'); -module.exports = { - entry: { - runtime: './src/runtime', - }, - output: { - filename: '[name].js', - path: resolve(process.cwd(), 'build'), - }, - optimization: { - runtimeChunk: { - name: 'vendors', +module.exports = [ + defaultConfig, + { + ...defaultConfig, + entry: { + runtime: './src/runtime', + 'blocks/favorites-number/view': + './src/blocks/favorites-number/view', + }, + output: { + filename: '[name].js', + path: resolve(process.cwd(), 'build'), }, - splitChunks: { - cacheGroups: { - vendors: { - test: /[\\/]node_modules[\\/]/, - name: 'vendors', - minSize: 0, - chunks: 'all', + optimization: { + runtimeChunk: { + name: 'vendors', + }, + splitChunks: { + cacheGroups: { + vendors: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + minSize: 0, + chunks: 'all', + }, }, }, }, - }, - module: { - rules: [ - { - test: /\.(j|t)sx?$/, - exclude: /node_modules/, - use: [ - { - loader: require.resolve('babel-loader'), - options: { - cacheDirectory: - process.env.BABEL_CACHE_DIRECTORY || true, - babelrc: false, - configFile: false, - presets: [ - [ - '@babel/preset-react', - { - runtime: 'automatic', - importSource: 'preact', - }, + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve('babel-loader'), + options: { + cacheDirectory: + process.env.BABEL_CACHE_DIRECTORY || true, + babelrc: false, + configFile: false, + presets: [ + [ + '@babel/preset-react', + { + runtime: 'automatic', + importSource: 'preact', + }, + ], ], - ], + }, }, - }, - ], - }, - ], + ], + }, + ], + }, }, -}; +]; diff --git a/wp-directives.php b/wp-directives.php index 68bb435a..60fb3e11 100644 --- a/wp-directives.php +++ b/wp-directives.php @@ -75,8 +75,14 @@ function wp_directives_add_wp_link_attribute($block_content) $link = parse_url($w->get_attribute('href')); if (!isset($link['host']) || $link['host'] === $site_url['host']) { $classes = $w->get_attribute('class'); - if (str_contains($classes, 'query-pagination') || str_contains($classes, 'page-numbers')) { - $w->set_attribute('wp-link', '{ "prefetch": true, "scroll": false }'); + if ( + str_contains($classes, 'query-pagination') || + str_contains($classes, 'page-numbers') + ) { + $w->set_attribute( + 'wp-link', + '{ "prefetch": true, "scroll": false }' + ); } else { $w->set_attribute('wp-link', '{ "prefetch": true }'); } @@ -85,8 +91,18 @@ function wp_directives_add_wp_link_attribute($block_content) return (string) $w; } // We go only through the Query Loops and the template parts until we find a better solution. -add_filter('render_block_core/query', 'wp_directives_add_wp_link_attribute', 10, 1); -add_filter('render_block_core/template-part', 'wp_directives_add_wp_link_attribute', 10, 1); +add_filter( + 'render_block_core/query', + 'wp_directives_add_wp_link_attribute', + 10, + 1 +); +add_filter( + 'render_block_core/template-part', + 'wp_directives_add_wp_link_attribute', + 10, + 1 +); function wp_directives_client_site_transitions_meta_tag() { @@ -106,3 +122,85 @@ function wp_directives_client_site_transitions_option() 'client_side_transitions', 'wp_directives_client_site_transitions_option' ); + +/* Blocks */ +add_action('init', function () { + register_block_type(__DIR__ . '/build/blocks/post-favorite'); + register_block_type(__DIR__ . '/build/blocks/favorites-number'); + register_block_type(__DIR__ . '/build/blocks/tabs'); +}); + +add_filter('render_block_bhe/favorites-number', function ($content) { + wp_enqueue_script( + 'bhe/favorites-number', + plugin_dir_url(__FILE__) . 'build/blocks/favorites-number/view.js' + ); + return $content; +}); + +/** + * SSR in PHP + */ + +// wp-show +function wpx_show_open_tag($prop_entries) +{ + global $wpx; + $props = wpx_prop_entries_to_array($prop_entries); + $attributes = wpx_prop_entries_to_attributes($prop_entries); + $value = wpx_get_state($props['when']); + if ($value) { + echo ""; + } else { + echo "'; + } +} + +// Utils +$GLOBALS['wpx'] = []; +function wpx($data) +{ + global $wpx; + $wpx = array_merge_recursive($wpx, $data); +} + +function wpx_get_state($path) +{ + global $wpx; + $current = $wpx; + $array = explode('.', $path); + foreach ($array as $p) { + $current = $current[$p]; + } + return $current; +} + +function wpx_prop_entries_to_array($prop_entries) +{ + $array = []; + foreach ($prop_entries as list($key, $value)) { + $array[$key] = $value; + } + return $array; +} + +function wpx_prop_entries_to_attributes($prop_entries) +{ + $attributes = ''; + foreach ($prop_entries as list($key, $value)) { + $attributes .= "$key='$value'"; + } + return $attributes; +}