diff --git a/package-lock.json b/package-lock.json index 1995c4c0468470..244113f1cd1074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7001,6 +7001,28 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.6.0.tgz", "integrity": "sha512-cPqjjzuFWNK3BSKLm0abspP0sp/IGOli4p5I5fKFAzdS8fvjdOwDCfZqAaIiXd9lPkOWi3SUUfZof3hEb7J/uw==" }, + "@preact/signals": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.1.3.tgz", + "integrity": "sha512-N09DuAVvc90bBZVRwD+aFhtGyHAmJLhS3IFoawO/bYJRcil4k83nBOchpCEoS0s5+BXBpahgp0Mjf+IOqP57Og==", + "requires": { + "@preact/signals-core": "^1.2.3" + } + }, + "@preact/signals-core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.2.3.tgz", + "integrity": "sha512-Kui4p7PMcEQevBgsTO0JBo3gyQ88Q3qzEvsVCuSp11t0JcN4DmGCTJcGRVSCq7Bn7lGxJBO+57jNSzDoDJ+QmA==" + }, + "@preact/signals-react": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@preact/signals-react/-/signals-react-1.2.2.tgz", + "integrity": "sha512-GoESQ9n1bns2FD+8yqH7lBvQMavboKLCNEW+s0hs3Wcp5B1VHvVxwJo6aFs6rpxoh1/q8Tvwbi4vIeehBD2mzA==", + "requires": { + "@preact/signals-core": "^1.2.3", + "use-sync-external-store": "^1.2.0" + } + }, "@react-native-clipboard/clipboard": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.9.0.tgz", @@ -25174,7 +25196,7 @@ "array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4=", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", "dev": true }, "array-includes": { @@ -28528,7 +28550,7 @@ "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", "dev": true }, "code-point-at": { @@ -30094,7 +30116,7 @@ "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, "cssesc": { @@ -30323,7 +30345,7 @@ "debuglog": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz", - "integrity": "sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=", + "integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==", "dev": true }, "decache": { @@ -30438,6 +30460,16 @@ "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", "dev": true }, + "deepsignal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.3.0.tgz", + "integrity": "sha512-tMh3g3F7Ka6gKALqu7uOzi/3Xm00mGWgWR3hp1GUzGGnTz2J926ime5aOe1haz233v4encyjTkZESr5R6hr8oQ==", + "requires": { + "@preact/signals": "^1.0.0", + "@preact/signals-core": "^1.0.0", + "@preact/signals-react": "^1.0.0" + } + }, "default-browser-id": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-1.0.4.tgz", @@ -35159,7 +35191,7 @@ "git-remote-origin-url": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz", - "integrity": "sha1-UoJlna4hBxRaERJhEq0yFuxfpl8=", + "integrity": "sha512-eU+GGrZgccNJcsDH5LkXR3PB9M958hxc7sbA8DFJjrv9j4L2P/eZfKhM+QD6wyzpiv+b1BpK0XrYCxkovtjSLw==", "dev": true, "requires": { "gitconfiglocal": "^1.0.0", @@ -35206,7 +35238,7 @@ "gitconfiglocal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz", - "integrity": "sha1-QdBF84UaXqiPA/JMocYXgRRGS5s=", + "integrity": "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==", "dev": true, "requires": { "ini": "^1.3.2" @@ -36485,7 +36517,7 @@ "humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", "dev": true, "requires": { "ms": "^2.0.0" @@ -37501,7 +37533,7 @@ "is-text-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-1.0.1.tgz", - "integrity": "sha1-Thqg+1G/vLPpJogAE5cgLBd1tm4=", + "integrity": "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==", "dev": true, "requires": { "text-extensions": "^1.0.0" @@ -39268,7 +39300,7 @@ "jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", "dev": true }, "jsprim": { @@ -40368,7 +40400,7 @@ "lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", - "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=", + "integrity": "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==", "dev": true }, "lodash.isplainobject": { @@ -40648,7 +40680,7 @@ "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", - "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", + "integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==", "dev": true }, "macos-release": { @@ -47031,6 +47063,11 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" }, + "preact": { + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.1.tgz", + "integrity": "sha512-KyoXVDU5OqTpG9LXlB3+y639JAGzl8JSBXLn1J9HTSB3gbKcuInga7bZnXLlxmK94ntTs1EFeZp0lrja2AuBYQ==" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -47649,7 +47686,7 @@ "promzard": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/promzard/-/promzard-0.3.0.tgz", - "integrity": "sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=", + "integrity": "sha512-JZeYqd7UAcHCwI+sTOeUDYkvEU+1bQ7iE0UT1MgB/tERkAPkesW46MrpIySzODi+owTjZtiF8Ay5j9m60KmMBw==", "dev": true, "requires": { "read": "1" @@ -47683,7 +47720,7 @@ "proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "dev": true }, "protocols": { @@ -49217,7 +49254,7 @@ "read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", "dev": true, "requires": { "mute-stream": "~0.0.4" @@ -54600,7 +54637,7 @@ "temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=", + "integrity": "sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==", "dev": true }, "terminal-link": { diff --git a/package.json b/package.json index 34d81fe27180ba..1d458d8084661e 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,8 @@ "@wordpress/warning": "file:packages/warning", "@wordpress/widgets": "file:packages/widgets", "@wordpress/wordcount": "file:packages/wordcount", + "deepsignal": "1.3.0", + "preact": "10.13.1", "wicg-inert": "3.1.2" }, "devDependencies": { diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 431ea2cedc4442..10af88a5c1a3ec 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -27,7 +27,7 @@ "sideEffects": [ "build-style/**", "src/**/*.scss", - "{src,build,build-module}/*/init.js" + "{src,build,build-module}/**/init.js" ], "dependencies": { "@babel/runtime": "^7.16.0", diff --git a/packages/block-library/src/post-comments-form/block.json b/packages/block-library/src/post-comments-form/block.json index 793d14d74ba7ba..a455f2d7612a35 100644 --- a/packages/block-library/src/post-comments-form/block.json +++ b/packages/block-library/src/post-comments-form/block.json @@ -13,6 +13,7 @@ }, "usesContext": [ "postId", "postType" ], "supports": { + "interactivity": true, "anchor": true, "html": false, "color": { @@ -44,5 +45,6 @@ "wp-block-post-comments-form", "wp-block-buttons", "wp-block-button" - ] + ], + "viewScript": [ "file:./view.min.js" ] } diff --git a/packages/block-library/src/post-comments-form/index.php b/packages/block-library/src/post-comments-form/index.php index 644b02ae0f1498..0e9f502591a0b2 100644 --- a/packages/block-library/src/post-comments-form/index.php +++ b/packages/block-library/src/post-comments-form/index.php @@ -45,11 +45,17 @@ function render_block_core_post_comments_form( $attributes, $content, $block ) { // to the block is carried along when the comment form is moved to the location // of the 'Reply' link that the user clicked by Core's `comment-reply.js` script. $form = str_replace( 'class="comment-respond"', $wrapper_attributes, $form ); + $form = str_replace( '', '
', $form ); // Enqueue the comment-reply script. - wp_enqueue_script( 'comment-reply' ); + // wp_enqueue_script( 'comment-reply' ); - return $form; + $tags = new WP_HTML_Tag_Processor( $form ); + + $tags->next_tag( array( 'tag_name' => 'FORM', 'id' => 'commentform' ) ); + $tags->set_attribute( 'data-wp-on.submit', 'actions.core.commentsFormSubmission' ); + + return $tags->get_updated_html(); } /** diff --git a/packages/block-library/src/post-comments-form/runtime/constants.js b/packages/block-library/src/post-comments-form/runtime/constants.js new file mode 100644 index 00000000000000..66aa781744c5e7 --- /dev/null +++ b/packages/block-library/src/post-comments-form/runtime/constants.js @@ -0,0 +1,3 @@ +export const csnMetaTagItemprop = 'wp-client-side-navigation'; +export const componentPrefix = 'wp-'; +export const directivePrefix = 'data-wp-'; diff --git a/packages/block-library/src/post-comments-form/runtime/directives.js b/packages/block-library/src/post-comments-form/runtime/directives.js new file mode 100644 index 00000000000000..0c2bbf2bf819ba --- /dev/null +++ b/packages/block-library/src/post-comments-form/runtime/directives.js @@ -0,0 +1,243 @@ +/** @jsx h */ + +/** + * External dependencies + */ +import { h } from 'preact'; + +import { useContext, useMemo, useEffect } from 'preact/hooks'; +import { useSignalEffect } from '@preact/signals'; +import { deepSignal, peek } from 'deepsignal'; +/** + * Internal dependencies + */ +import { directive } from './hooks'; +import { prefetch, navigate, canDoClientSideNavigation } from './router'; + +// Until useSignalEffects is fixed: +// https://github.com/preactjs/signals/issues/228 +const raf = window.requestAnimationFrame; +const tick = () => new Promise( ( r ) => raf( () => raf( r ) ) ); + +// Check if current page can do client-side navigation. +const clientSideNavigation = canDoClientSideNavigation( document.head ); + +const isObject = ( item ) => + item && typeof item === 'object' && ! Array.isArray( item ); + +const mergeDeepSignals = ( target, source ) => { + for ( const k in source ) { + if ( typeof peek( target, k ) === 'undefined' ) { + target[ `$${ k }` ] = source[ `$${ k }` ]; + } else if ( + isObject( peek( target, k ) ) && + isObject( peek( source, k ) ) + ) { + mergeDeepSignals( + target[ `$${ k }` ].peek(), + source[ `$${ k }` ].peek() + ); + } + } +}; + +export default () => { + // data-wp-context + directive( + 'context', + ( { + directives: { + context: { default: context }, + }, + props: { children }, + context: inherited, + } ) => { + const { Provider } = inherited; + const inheritedValue = useContext( inherited ); + const value = useMemo( () => { + const localValue = deepSignal( context ); + mergeDeepSignals( localValue, inheritedValue ); + return localValue; + }, [ context, inheritedValue ] ); + + return { children }; + } + ); + + // data-wp-effect.[name] + directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.values( effect ).forEach( ( path ) => { + useSignalEffect( () => { + evaluate( path, { context: contextValue } ); + } ); + } ); + } ); + + // data-wp-on.[event] + directive( 'on', ( { directives: { on }, element, evaluate, context } ) => { + const contextValue = useContext( context ); + Object.entries( on ).forEach( ( [ name, path ] ) => { + element.props[ `on${ name }` ] = ( event ) => { + evaluate( path, { event, context: contextValue } ); + }; + } ); + } ); + + // data-wp-class.[classname] + directive( + 'class', + ( { + directives: { class: className }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + Object.keys( className ) + .filter( ( n ) => n !== 'default' ) + .forEach( ( name ) => { + const result = evaluate( className[ name ], { + className: name, + context: contextValue, + } ); + const currentClass = element.props.class || ''; + const classFinder = new RegExp( + `(^|\\s)${ name }(\\s|$)`, + 'g' + ); + if ( ! result ) + element.props.class = currentClass + .replace( classFinder, ' ' ) + .trim(); + else if ( ! classFinder.test( currentClass ) ) + element.props.class = currentClass + ? `${ currentClass } ${ name }` + : name; + + useEffect( () => { + // This seems necessary because Preact doesn't change the class names + // on the hydration, so we have to do it manually. It doesn't need + // deps because it only needs to do it the first time. + if ( ! result ) { + element.ref.current.classList.remove( name ); + } else { + element.ref.current.classList.add( name ); + } + }, [] ); + } ); + } + ); + + // data-wp-bind.[attribute] + directive( + 'bind', + ( { directives: { bind }, element, context, evaluate } ) => { + const contextValue = useContext( context ); + Object.entries( bind ) + .filter( ( n ) => n !== 'default' ) + .forEach( ( [ attribute, path ] ) => { + element.props[ attribute ] = evaluate( path, { + context: contextValue, + } ); + } ); + } + ); + + // data-wp-link + directive( + 'link', + ( { + directives: { + link: { default: link }, + }, + props: { href }, + element, + } ) => { + useEffect( () => { + // Prefetch the page if it is in the directive options. + if ( clientSideNavigation && link?.prefetch ) { + prefetch( href ); + } + } ); + + // Don't do anything if it's falsy. + if ( clientSideNavigation && link !== false ) { + element.props.onclick = async ( event ) => { + 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 ); + } + }; + } + } + ); + + // data-wp-show + directive( + 'show', + ( { + directives: { + show: { default: show }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + if ( ! evaluate( show, { context: contextValue } ) ) + element.props.children = ( + + ); + } + ); + + // data-wp-ignore + directive( + 'ignore', + ( { + element: { + type: Type, + props: { innerHTML, ...rest }, + }, + } ) => { + // Preserve the initial inner HTML. + const cached = useMemo( () => innerHTML, [] ); + return ( + + ); + } + ); + + // data-wp-text + directive( + 'text', + ( { + directives: { + text: { default: text }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + element.props.children = evaluate( text, { + context: contextValue, + } ); + } + ); +}; diff --git a/packages/block-library/src/post-comments-form/runtime/hooks.js b/packages/block-library/src/post-comments-form/runtime/hooks.js new file mode 100644 index 00000000000000..40b9f320952e1c --- /dev/null +++ b/packages/block-library/src/post-comments-form/runtime/hooks.js @@ -0,0 +1,85 @@ +import { h, options, createContext } from 'preact'; +import { useRef } from 'preact/hooks'; +import { rawStore as store } from './store'; +import { componentPrefix } from './constants'; + +// Main context. +const context = createContext({}); + +// WordPress Directives. +const directiveMap = {}; +export const directive = (name, cb) => { + directiveMap[name] = cb; +}; + +// WordPress Components. +const componentMap = {}; +export const component = (name, Comp) => { + componentMap[name] = Comp; +}; + +// Resolve the path to some property of the store object. +const resolve = (path, context) => { + let current = { ...store, context }; + path.split('.').forEach((p) => (current = current[p])); + return current; +}; + +// Generate the evaluate function. +const getEvaluate = + ({ ref } = {}) => + (path, extraArgs = {}) => { + const value = resolve(path, extraArgs.context); + return typeof value === 'function' + ? value({ + state: store.state, + ...(ref !== undefined ? { ref } : {}), + ...extraArgs, + }) + : value; + }; + +// Directive wrapper. +const Directive = ({ type, directives, props: originalProps }) => { + const ref = useRef(null); + const element = h(type, { ...originalProps, ref, _wrapped: true }); + const props = { ...originalProps, children: element }; + const evaluate = getEvaluate({ ref: ref.current }); + const directiveArgs = { directives, props, element, context, evaluate }; + + for (const d in directives) { + const wrapper = directiveMap[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 { directives } = vnode.props; + + if ( + typeof type === 'string' && + type.slice(0, componentPrefix.length) === componentPrefix + ) { + vnode.props.children = h( + componentMap[type.slice(componentPrefix.length)], + { ...vnode.props, context, evaluate: getEvaluate() }, + vnode.props.children + ); + } else if (directives) { + const props = vnode.props; + delete props.directives; + if (!props._wrapped) { + vnode.props = { type: vnode.type, directives, props }; + vnode.type = Directive; + } else { + delete props._wrapped; + } + } + + if (old) old(vnode); +}; diff --git a/packages/block-library/src/post-comments-form/runtime/index.js b/packages/block-library/src/post-comments-form/runtime/index.js new file mode 100644 index 00000000000000..926021bb579790 --- /dev/null +++ b/packages/block-library/src/post-comments-form/runtime/index.js @@ -0,0 +1,2 @@ +export { store } from './store'; +export { navigate } from './router'; diff --git a/packages/block-library/src/post-comments-form/runtime/init.js b/packages/block-library/src/post-comments-form/runtime/init.js new file mode 100644 index 00000000000000..772a690c3279fd --- /dev/null +++ b/packages/block-library/src/post-comments-form/runtime/init.js @@ -0,0 +1,11 @@ +/** + * Internal dependencies + */ +import registerDirectives from './directives'; +import { init } from './router'; + +document.addEventListener( 'DOMContentLoaded', async () => { + registerDirectives(); + await init(); + console.log( 'hydrated!' ); +} ); diff --git a/packages/block-library/src/post-comments-form/runtime/router.js b/packages/block-library/src/post-comments-form/runtime/router.js new file mode 100644 index 00000000000000..e813e1c1f19b92 --- /dev/null +++ b/packages/block-library/src/post-comments-form/runtime/router.js @@ -0,0 +1,171 @@ +/** + * External dependencies + */ +import { hydrate, render } from 'preact'; +/** + * Internal dependencies + */ +import { toVdom, hydratedIslands } from './vdom'; +import { createRootFragment } from './utils'; +import { csnMetaTagItemprop, directivePrefix } from './constants'; + +// The root to render the vdom (document.body). +let rootFragment; + +// The cache of visited and prefetched pages, stylesheets and scripts. +const pages = new Map(); +const stylesheets = new Map(); +const scripts = new Map(); + +// Helper to remove domain and hash from the URL. We are only interesting in +// caching the path and the query. +const cleanUrl = ( url ) => { + const u = new URL( url, window.location ); + return u.pathname + u.search; +}; + +// Helper to check if a page can do client-side navigation. +export const canDoClientSideNavigation = ( dom ) => + dom + .querySelector( `meta[itemprop='${ csnMetaTagItemprop }']` ) + ?.getAttribute( 'content' ) === 'active'; + +/** + * Finds the elements in the document that match the selector and fetch them. + * For each element found, fetch the content and store it in the cache. + * Returns an array of elements to add to the document. + * + * @param document + * @param {string} selector - CSS selector used to find the elements. + * @param {'href'|'src'} attribute - Attribute that determines where to fetch + * the styles or scripts from. Also used as the key for the cache. + * @param {Map} cache - Cache to use for the elements. Can be `stylesheets` or `scripts`. + * @param {'style'|'script'} elementToCreate - Element to create for each fetched + * item. Can be 'style' or 'script'. + * @return {Promise>} - Array of elements to add to the document. + */ +const fetchScriptOrStyle = async ( + document, + selector, + attribute, + cache, + elementToCreate +) => { + const fetchedItems = await Promise.all( + [].map.call( document.querySelectorAll( selector ), ( el ) => { + const attributeValue = el.getAttribute( attribute ); + if ( ! cache.has( attributeValue ) ) + cache.set( + attributeValue, + fetch( attributeValue ).then( ( r ) => r.text() ) + ); + return cache.get( attributeValue ); + } ) + ); + + return fetchedItems.map( ( item ) => { + const element = document.createElement( elementToCreate ); + element.textContent = item; + return element; + } ); +}; + +// Fetch styles of a new page. +const fetchAssets = async ( document ) => { + const stylesFromSheets = await fetchScriptOrStyle( + document, + 'link[rel=stylesheet]', + 'href', + stylesheets, + 'style' + ); + const scriptTags = await fetchScriptOrStyle( + document, + 'script[src]', + 'src', + scripts, + 'script' + ); + const moduleScripts = await fetchScriptOrStyle( + document, + 'script[type=module]', + 'src', + scripts, + 'script' + ); + moduleScripts.forEach( ( script ) => + script.setAttribute( 'type', 'module' ) + ); + + return [ + ...scriptTags, + document.querySelector( 'title' ), + ...document.querySelectorAll( 'style' ), + ...stylesFromSheets, + ]; +}; + +// Fetch a new page and convert it to a static virtual DOM. +const fetchPage = async ( url, options = {} ) => { + const html = + options.html || ( await window.fetch( url ).then( ( r ) => r.text() ) ); + const dom = new window.DOMParser().parseFromString( html, 'text/html' ); + const head = await fetchAssets( dom ); + return { head, body: toVdom( dom.body ) }; +}; + +// Prefetch a page. We store the promise to avoid triggering a second fetch for +// a page if a fetching has already started. +export const prefetch = ( url, options = {} ) => { + url = cleanUrl( url ); + if ( options.force || ! pages.has( url ) ) { + pages.set( url, fetchPage( url, options ) ); + } +}; + +// Navigate to a new page. +export const navigate = async ( href, options = {} ) => { + const url = cleanUrl( href ); + prefetch( url, options ); + const page = await pages.get( url ); + if ( page ) { + document.head.replaceChildren( ...page.head ); + render( page.body, rootFragment ); + window.history.pushState( {}, '', href ); + } else { + window.location.assign( href ); + } +}; + +// Listen to the back and forward buttons and restore the page if it's in the +// 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 ); + } else { + window.location.reload(); + } +} ); + +// Initialize the router with the initial DOM. +export const init = async () => { + // Create the root fragment to hydrate everything. + rootFragment = createRootFragment( + document.documentElement, + document.body + ); + + const body = toVdom( document.body ); + hydrate( body, rootFragment ); + + // Cache the scripts. Has to be called before fetching the assets. + [].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => { + scripts.set( script.getAttribute( 'src' ), script.textContent ); + } ); + + const head = await fetchAssets( document ); + pages.set( cleanUrl( window.location ), Promise.resolve( { body, head } ) ); +}; diff --git a/packages/block-library/src/post-comments-form/runtime/store.js b/packages/block-library/src/post-comments-form/runtime/store.js new file mode 100644 index 00000000000000..eca682a194e844 --- /dev/null +++ b/packages/block-library/src/post-comments-form/runtime/store.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { deepSignal } from 'deepsignal'; + +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 ] } ); + } + } + } +}; + +const getSerializedState = () => { + // TODO: change the store tag ID for a better one. + const storeTag = document.querySelector( + `script[type="application/json"]#store` + ); + if ( ! storeTag ) return {}; + try { + const { state } = JSON.parse( storeTag.textContent ); + if ( isObject( state ) ) return state; + throw Error( 'Parsed state is not an object' ); + } catch ( e ) { + console.log( e ); + } + return {}; +}; + +const rawState = getSerializedState(); +export const rawStore = { state: deepSignal( rawState ) }; + +if ( typeof window !== 'undefined' ) window.store = rawStore; + +export const store = ( { state, ...block } ) => { + deepMerge( rawStore, block ); + deepMerge( rawState, state ); +}; diff --git a/packages/block-library/src/post-comments-form/runtime/utils.js b/packages/block-library/src/post-comments-form/runtime/utils.js new file mode 100644 index 00000000000000..f1748293cad99f --- /dev/null +++ b/packages/block-library/src/post-comments-form/runtime/utils.js @@ -0,0 +1,20 @@ +// 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); + }, + }); +}; diff --git a/packages/block-library/src/post-comments-form/runtime/vdom.js b/packages/block-library/src/post-comments-form/runtime/vdom.js new file mode 100644 index 00000000000000..f1cd54420a86a4 --- /dev/null +++ b/packages/block-library/src/post-comments-form/runtime/vdom.js @@ -0,0 +1,71 @@ +import { h } from 'preact'; +import { directivePrefix as p } from './constants'; + +const ignoreAttr = `${p}ignore`; +const islandAttr = `${p}island`; +const directiveParser = new RegExp(`${p}([^.]+)\.?(.*)$`); + +export const hydratedIslands = new WeakSet(); + +// Recursive function that transfoms a DOM tree into vDOM. +export function toVdom(node) { + const props = {}; + const { attributes, childNodes } = node; + const directives = {}; + let hasDirectives = false; + let ignore = false; + let island = false; + + if (node.nodeType === 3) return node.data; + if (node.nodeType === 4) { + node.replaceWith(new Text(node.nodeValue)); + return node.nodeValue; + } + + for (let i = 0; i < attributes.length; i++) { + const n = attributes[i].name; + if (n[p.length] && n.slice(0, p.length) === p) { + if (n === ignoreAttr) { + ignore = true; + } else if (n === islandAttr) { + island = true; + } else { + hasDirectives = true; + let val = attributes[i].value; + try { + val = JSON.parse(val); + } catch (e) {} + const [, prefix, suffix] = directiveParser.exec(n); + directives[prefix] = directives[prefix] || {}; + directives[prefix][suffix || 'default'] = val; + } + } else if (n === 'ref') { + continue; + } else { + props[n] = attributes[i].value; + } + } + + if (ignore && !island) + return h(node.localName, { + ...props, + innerHTML: node.innerHTML, + directives: { ignore: true }, + }); + if (island) hydratedIslands.add(node); + + if (hasDirectives) props.directives = directives; + + const children = []; + for (let i = 0; i < childNodes.length; i++) { + const child = childNodes[i]; + if (child.nodeType === 8 || child.nodeType === 7) { + child.remove(); + i--; + } else { + children.push(toVdom(child)); + } + } + + return h(node.localName, props, children); +} diff --git a/packages/block-library/src/post-comments-form/view.js b/packages/block-library/src/post-comments-form/view.js new file mode 100644 index 00000000000000..f42d16ad749f24 --- /dev/null +++ b/packages/block-library/src/post-comments-form/view.js @@ -0,0 +1,50 @@ +/** + * Internal dependencies + */ +import './runtime/init.js'; +import { store, navigate } from './runtime'; + +store( { + state: { + core: { + commentsFormError: '', + }, + }, + actions: { + core: { + commentsFormSubmission: async ( { event, state } ) => { + event.preventDefault(); + + state.core.commentsFormError = ''; + + const formData = new FormData( event.target ); + + const res = await fetch( + 'http://localhost:8888/wp-comments-post.php', + { + method: 'POST', + body: formData, + } + ); + + const html = await res.text(); + + if ( res.status !== 200 ) { + const dom = new window.DOMParser().parseFromString( + html, + 'text/html' + ); + state.core.commentsFormError = + dom.querySelector( 'p' ).innerHTML; + } else { + navigate( res.url, { + html, + force: true, + } ); + + event.target.reset(); + } + }, + }, + }, +} );