diff --git a/package-lock.json b/package-lock.json index e070dc114fa5d..bea9bb81b0fc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8113,6 +8113,19 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.6.0.tgz", "integrity": "sha512-cPqjjzuFWNK3BSKLm0abspP0sp/IGOli4p5I5fKFAzdS8fvjdOwDCfZqAaIiXd9lPkOWi3SUUfZof3hEb7J/uw==" }, + "@preact/signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.1.1.tgz", + "integrity": "sha512-I1DhYo2d1t9qDkEq1jYDVTQdBGmo4NlqatNEtulsS/87kVdwhZluP6TTDS4/5sc2h86TlBF6UA6LO+tDpIt/Gw==", + "requires": { + "@preact/signals-core": "^1.2.0" + } + }, + "@preact/signals-core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.2.1.tgz", + "integrity": "sha512-aqzRMNFU1hoQyP4Kb1ldJrUTCnA9vqPDa7qHEQzHJ3upnBOcC2pjmvjAuTqGuY4AVTtUkCQV0FvOCuIQQ2hSdA==" + }, "@reach/router": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/@reach/router/-/router-1.3.4.tgz", @@ -17373,6 +17386,7 @@ "version": "file:packages/block-library", "requires": { "@babel/runtime": "^7.16.0", + "@preact/signals": "^1.1.1", "@wordpress/a11y": "file:packages/a11y", "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/autop": "file:packages/autop", @@ -17406,6 +17420,7 @@ "lodash": "^4.17.21", "memize": "^1.1.0", "micromodal": "^0.4.10", + "preact": "^10.11.0", "remove-accents": "^0.4.2" } }, @@ -49896,6 +49911,11 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" }, + "preact": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.0.tgz", + "integrity": "sha512-Fk6+vB2kb6mSJfDgODq0YDhMfl0HNtK5+Uc9QqECO4nlyPAQwCI+BKyWO//idA7ikV7o+0Fm6LQmNuQi1wXI1w==" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", diff --git a/packages/block-library/package.json b/packages/block-library/package.json index b99743fd0dcba..cf38570458963 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -31,6 +31,7 @@ ], "dependencies": { "@babel/runtime": "^7.16.0", + "@preact/signals": "^1.1.1", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/autop": "file:../autop", @@ -64,6 +65,7 @@ "lodash": "^4.17.21", "memize": "^1.1.0", "micromodal": "^0.4.10", + "preact": "^10.11.0", "remove-accents": "^0.4.2" }, "peerDependencies": { diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index a8c65b5a88b75..781251b101aa8 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -130,7 +130,7 @@ } } }, - "viewScript": [ "file:./view.min.js", "file:./view-modal.min.js" ], + "viewScript": [ "file:./view.min.js" ], "editorStyle": "wp-block-navigation-editor", "style": "wp-block-navigation" } diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 5173f96040b4d..9c594696f9446 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -428,7 +428,7 @@ function render_block_core_navigation( $attributes, $content, $block ) { $should_load_modal_view_script = isset( $attributes['overlayMenu'] ) && 'never' !== $attributes['overlayMenu']; if ( $should_load_modal_view_script ) { - wp_enqueue_script( 'wp-block-navigation-view-modal' ); + // wp_enqueue_script( 'wp-block-navigation-view-modal' ); } $inner_blocks = $block->inner_blocks; @@ -556,6 +556,7 @@ function render_block_core_navigation( $attributes, $content, $block ) { $inner_blocks_html = ''; $is_list_open = false; + $is_first_item = false; foreach ( $inner_blocks as $inner_block ) { if ( ( 'core/navigation-link' === $inner_block->name || 'core/home-link' === $inner_block->name || 'core/site-title' === $inner_block->name || 'core/site-logo' === $inner_block->name || 'core/navigation-submenu' === $inner_block->name ) && ! $is_list_open ) { $is_list_open = true; @@ -566,6 +567,13 @@ function render_block_core_navigation( $attributes, $content, $block ) { $inner_blocks_html .= ''; } $inner_block_content = $inner_block->render(); + if (!$is_first_item) { + $w = new WP_HTML_Tag_Processor($inner_block_content); + $w->next_tag('a'); + $w->set_attribute('wp-effect', 'core.navigation.addFirstElementToContext'); + $inner_block_content = (string) $w; + $is_first_item = true; + } if ( 'core/site-title' === $inner_block->name || ( 'core/site-logo' === $inner_block->name && $inner_block_content ) ) { $inner_blocks_html .= '
  • ' . $inner_block_content . '
  • '; } else { @@ -632,17 +640,33 @@ function render_block_core_navigation( $attributes, $content, $block ) { $toggle_aria_label_close = $should_display_icon_label ? 'aria-label="' . __( 'Close menu' ) . '"' : ''; // Close button label. $responsive_container_markup = sprintf( - ' -
    -
    -
    - -
    - %2$s -
    + ' +
    +
    + -
    ', +
    +
    ', esc_attr( $modal_unique_id ), $inner_blocks_html, $toggle_aria_label_open, @@ -656,7 +680,7 @@ function render_block_core_navigation( $attributes, $content, $block ) { ); return sprintf( - '', + '', $wrapper_attributes, $responsive_container_markup ); diff --git a/packages/block-library/src/navigation/view-modal.js b/packages/block-library/src/navigation/view-modal.js deleted file mode 100644 index 477f05b5d5e51..0000000000000 --- a/packages/block-library/src/navigation/view-modal.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * External dependencies - */ -import MicroModal from 'micromodal'; - -// Responsive navigation toggle. -function navigationToggleModal( modal ) { - const dialogContainer = modal.querySelector( - `.wp-block-navigation__responsive-dialog` - ); - - const isHidden = 'true' === modal.getAttribute( 'aria-hidden' ); - - modal.classList.toggle( 'has-modal-open', ! isHidden ); - dialogContainer.toggleAttribute( 'aria-modal', ! isHidden ); - - if ( isHidden ) { - dialogContainer.removeAttribute( 'role' ); - dialogContainer.removeAttribute( 'aria-modal' ); - } else { - dialogContainer.setAttribute( 'role', 'dialog' ); - dialogContainer.setAttribute( 'aria-modal', 'true' ); - } - - // Add a class to indicate the modal is open. - const htmlElement = document.documentElement; - htmlElement.classList.toggle( 'has-modal-open' ); -} - -window.addEventListener( 'load', () => { - MicroModal.init( { - onShow: navigationToggleModal, - onClose: navigationToggleModal, - openClass: 'is-menu-open', - } ); - - // Close modal automatically on clicking anchor links inside modal. - const navigationLinks = document.querySelectorAll( - '.wp-block-navigation-item__content' - ); - - navigationLinks.forEach( function ( link ) { - // Ignore non-anchor links and anchor links which open on a new tab. - if ( - ! link.getAttribute( 'href' )?.startsWith( '#' ) || - link.attributes?.target === '_blank' - ) { - return; - } - - // Find the specific parent modal for this link - // since .close() won't work without an ID if there are - // multiple navigation menus in a post/page. - const modal = link.closest( - '.wp-block-navigation__responsive-container' - ); - const modalId = modal?.getAttribute( 'id' ); - - link.addEventListener( 'click', () => { - // check if modal exists and is open before trying to close it - // otherwise Micromodal will toggle the `has-modal-open` class - // on the html tag which prevents scrolling - if ( modalId && modal.classList.contains( 'has-modal-open' ) ) { - MicroModal.close( modalId ); - } - } ); - } ); -} ); diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index 19805a44ae4ae..916732f6dedea 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -1,74 +1,34 @@ -// Open on click functionality. -function closeSubmenus( element ) { - element - .querySelectorAll( '[aria-expanded="true"]' ) - .forEach( function ( toggle ) { - toggle.setAttribute( 'aria-expanded', 'false' ); - } ); -} +/** + * Internal dependencies + */ +import wpx from './wpx'; -function toggleSubmenuOnClick( event ) { - const buttonToggle = event.target.closest( '[aria-expanded]' ); - const isSubmenuOpen = buttonToggle.getAttribute( 'aria-expanded' ); - - if ( isSubmenuOpen === 'true' ) { - closeSubmenus( buttonToggle.closest( '.wp-block-navigation-item' ) ); - } else { - // Close all sibling submenus. - const parentElement = buttonToggle.closest( - '.wp-block-navigation-item' - ); - const navigationParent = buttonToggle.closest( - '.wp-block-navigation__submenu-container, .wp-block-navigation__container, .wp-block-page-list' - ); - navigationParent - .querySelectorAll( '.wp-block-navigation-item' ) - .forEach( function ( child ) { - if ( child !== parentElement ) { - closeSubmenus( child ); +wpx( { + core: { + navigation: { + openMenu: ( { context: { navigation } } ) => { + navigation.open = true; + navigation.previousElementWithFocus = + window.document.activeElement; + }, + closeMenu: ( { context: { navigation } } ) => { + navigation.open = false; + }, + isMenuOpen: ( { context: { navigation } } ) => navigation.open, + isMenuClosed: ( { context: { navigation } } ) => ! navigation.open, + addFirstElementToContext: ( { context: { navigation }, ref } ) => { + navigation.firstMenuElement = ref; + }, + focusFirstElement: async ( { context: { navigation }, tick } ) => { + if ( navigation.open ) { + await tick(); // We need to wait until the DOM is updated. + navigation.firstMenuElement.focus(); } - } ); - // Open submenu. - buttonToggle.setAttribute( 'aria-expanded', 'true' ); - } -} - -// Necessary for some themes such as TT1 Blocks, where -// scripts could be loaded before the body. -window.addEventListener( 'load', () => { - const submenuButtons = document.querySelectorAll( - '.wp-block-navigation-submenu__toggle' - ); - - submenuButtons.forEach( function ( button ) { - button.addEventListener( 'click', toggleSubmenuOnClick ); - } ); - - // Close on click outside. - document.addEventListener( 'click', function ( event ) { - const navigationBlocks = document.querySelectorAll( - '.wp-block-navigation' - ); - navigationBlocks.forEach( function ( block ) { - if ( ! block.contains( event.target ) ) { - closeSubmenus( block ); - } - } ); - } ); - // Close on focus outside or escape key. - document.addEventListener( 'keyup', function ( event ) { - const submenuBlocks = document.querySelectorAll( - '.wp-block-navigation-item.has-child' - ); - submenuBlocks.forEach( function ( block ) { - if ( ! block.contains( event.target ) ) { - closeSubmenus( block ); - } else if ( event.key === 'Escape' ) { - const toggle = block.querySelector( '[aria-expanded="true"]' ); - closeSubmenus( block ); - // Focus the submenu trigger so focus does not get trapped in the closed submenu. - toggle?.focus(); - } - } ); - } ); + }, + focusLastFocusedElement: ( { context: { navigation } } ) => { + if ( ! navigation.open && navigation.previousElementWithFocus ) + navigation.previousElementWithFocus.focus(); + }, + }, + }, } ); diff --git a/packages/block-library/src/navigation/wpx/components.js b/packages/block-library/src/navigation/wpx/components.js new file mode 100644 index 0000000000000..3cfc473775414 --- /dev/null +++ b/packages/block-library/src/navigation/wpx/components.js @@ -0,0 +1,23 @@ +// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable jsdoc/check-tag-names */ +/** @jsx h */ + +/** + * External dependencies + */ +import { h } from 'preact'; +import { useMemo } from 'preact/hooks'; + +/** + * Internal dependencies + */ +import { deepSignal } from './deep-signal'; +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/packages/block-library/src/navigation/wpx/deep-signal.js b/packages/block-library/src/navigation/wpx/deep-signal.js new file mode 100644 index 0000000000000..707cb56da79dc --- /dev/null +++ b/packages/block-library/src/navigation/wpx/deep-signal.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { signal } from '@preact/signals'; + +/** + * Internal dependencies + */ +import { knownSymbols, shouldWrap } from './utils'; + +const proxyToSignals = new WeakMap(); +const objToProxy = new WeakMap(); + +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 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 ); + 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/packages/block-library/src/navigation/wpx/directives.js b/packages/block-library/src/navigation/wpx/directives.js new file mode 100644 index 0000000000000..3f90f03520e48 --- /dev/null +++ b/packages/block-library/src/navigation/wpx/directives.js @@ -0,0 +1,102 @@ +// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable jsdoc/check-tag-names */ +// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable no-eval */ + +/** @jsx h */ + +/** + * External dependencies + */ +import { h } from 'preact'; +import { useContext, useMemo } from 'preact/hooks'; +import { useSignalEffect } from '@preact/signals'; + +/** + * Internal dependencies + */ +import { directive } from './hooks'; +import { deepSignal } from './deep-signal'; +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/packages/block-library/src/navigation/wpx/hooks.js b/packages/block-library/src/navigation/wpx/hooks.js new file mode 100644 index 0000000000000..72e4429d4af44 --- /dev/null +++ b/packages/block-library/src/navigation/wpx/hooks.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +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/packages/block-library/src/navigation/wpx/index.js b/packages/block-library/src/navigation/wpx/index.js new file mode 100644 index 0000000000000..f09b2c787c207 --- /dev/null +++ b/packages/block-library/src/navigation/wpx/index.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { hydrate } from 'preact'; + +/** + * Internal dependencies + */ +import registerDirectives from './directives'; +import registerComponents from './components'; +import toVdom from './vdom'; +import { createRootFragment, idle, deepMerge } 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 + ); + + await idle(); // Wait until the CPU is idle to do the hydration. + const vdom = toVdom( document.body ); + hydrate( vdom, rootFragment ); + + // eslint-disable-next-line no-console + console.log( 'hydrated!' ); +} ); + +window.wpx = window.wpx || {}; + +export default ( block ) => { + deepMerge( window.wpx, block ); +}; diff --git a/packages/block-library/src/navigation/wpx/utils.js b/packages/block-library/src/navigation/wpx/utils.js new file mode 100644 index 0000000000000..89875a093aed3 --- /dev/null +++ b/packages/block-library/src/navigation/wpx/utils.js @@ -0,0 +1,77 @@ +// eslint-disable-next-line eslint-comments/disable-enable-pair +/* eslint-disable no-undef */ + +// 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/packages/block-library/src/navigation/wpx/vdom.js b/packages/block-library/src/navigation/wpx/vdom.js new file mode 100644 index 0000000000000..11940245b6621 --- /dev/null +++ b/packages/block-library/src/navigation/wpx/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;