first commit
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { useMemo, useContext } from 'preact/hooks';
|
||||
import { deepSignal } from 'deepsignal';
|
||||
import { component } from './hooks';
|
||||
|
||||
export default () => {
|
||||
// <wp-context>
|
||||
const Context = ( { children, data, context: { Provider } } ) => {
|
||||
const signals = useMemo(
|
||||
() => deepSignal( JSON.parse( data ) ),
|
||||
[ data ]
|
||||
);
|
||||
return <Provider value={ signals }>{ children }</Provider>;
|
||||
};
|
||||
component( 'context', Context );
|
||||
|
||||
// <wp-show>
|
||||
const Show = ( { children, when, evaluate, context } ) => {
|
||||
const contextValue = useContext( context );
|
||||
if ( evaluate( when, { context: contextValue } ) ) {
|
||||
return children;
|
||||
} else {
|
||||
return <template>{ children }</template>;
|
||||
}
|
||||
};
|
||||
component( 'show', Show );
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const csnMetaTagItemprop = 'woo-client-side-navigation';
|
||||
export const componentPrefix = 'woo-';
|
||||
export const directivePrefix = 'data-woo-';
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useContext, useMemo, useEffect } from 'preact/hooks';
|
||||
import { useSignalEffect } from '@preact/signals';
|
||||
import { deepSignal, peek } from 'deepsignal';
|
||||
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 () => {
|
||||
// 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 <Provider value={ value }>{ children }</Provider>;
|
||||
}
|
||||
);
|
||||
|
||||
// wp-effect:[name]
|
||||
directive( 'effect', ( { directives: { effect }, context, evaluate } ) => {
|
||||
const contextValue = useContext( context );
|
||||
Object.values( effect ).forEach( ( path ) => {
|
||||
useSignalEffect( () => {
|
||||
evaluate( path, { context: contextValue } );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
// 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 } );
|
||||
};
|
||||
} );
|
||||
} );
|
||||
|
||||
// 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 );
|
||||
}
|
||||
}, [] );
|
||||
} );
|
||||
}
|
||||
);
|
||||
|
||||
// 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,
|
||||
} );
|
||||
} );
|
||||
}
|
||||
);
|
||||
|
||||
// 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 );
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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 );
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import registerDirectives from './directives';
|
||||
import registerComponents from './components';
|
||||
import { init } from './router';
|
||||
export { store } from './store';
|
||||
|
||||
/**
|
||||
* Initialize the initial vDOM.
|
||||
*/
|
||||
document.addEventListener( 'DOMContentLoaded', async () => {
|
||||
registerDirectives();
|
||||
registerComponents();
|
||||
await init();
|
||||
console.log( 'hydrated!' );
|
||||
} );
|
||||
@@ -0,0 +1,130 @@
|
||||
import { hydrate, render } from 'preact';
|
||||
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 and stylesheets.
|
||||
const pages = new Map();
|
||||
const stylesheets = 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';
|
||||
|
||||
// Fetch styles of a new page.
|
||||
const fetchHead = async ( head ) => {
|
||||
const sheets = await Promise.all(
|
||||
[].map.call(
|
||||
head.querySelectorAll( "link[rel='stylesheet']" ),
|
||||
( link ) => {
|
||||
const href = link.getAttribute( 'href' );
|
||||
if ( ! stylesheets.has( href ) )
|
||||
stylesheets.set(
|
||||
href,
|
||||
fetch( href ).then( ( r ) => r.text() )
|
||||
);
|
||||
return stylesheets.get( href );
|
||||
}
|
||||
)
|
||||
);
|
||||
const stylesFromSheets = sheets.map( ( sheet ) => {
|
||||
const style = document.createElement( 'style' );
|
||||
style.textContent = sheet;
|
||||
return style;
|
||||
} );
|
||||
return [
|
||||
head.querySelector( 'title' ),
|
||||
...head.querySelectorAll( 'style' ),
|
||||
...stylesFromSheets,
|
||||
];
|
||||
};
|
||||
|
||||
// Fetch a new page and convert it to a static virtual DOM.
|
||||
const fetchPage = async ( url ) => {
|
||||
const html = await window.fetch( url ).then( ( r ) => r.text() );
|
||||
const dom = new window.DOMParser().parseFromString( html, 'text/html' );
|
||||
if ( ! canDoClientSideNavigation( dom.head ) ) return false;
|
||||
const head = await fetchHead( dom.head );
|
||||
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 ) => {
|
||||
url = cleanUrl( url );
|
||||
if ( ! pages.has( url ) ) {
|
||||
pages.set( url, fetchPage( url ) );
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to a new page.
|
||||
export const navigate = async ( href ) => {
|
||||
const url = cleanUrl( href );
|
||||
prefetch( url );
|
||||
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 () => {
|
||||
if ( canDoClientSideNavigation( document.head ) ) {
|
||||
// Create the root fragment to hydrate everything.
|
||||
rootFragment = createRootFragment(
|
||||
document.documentElement,
|
||||
document.body
|
||||
);
|
||||
|
||||
const body = toVdom( document.body );
|
||||
hydrate( body, rootFragment );
|
||||
|
||||
const head = await fetchHead( document.head );
|
||||
pages.set(
|
||||
cleanUrl( window.location ),
|
||||
Promise.resolve( { body, head } )
|
||||
);
|
||||
} else {
|
||||
document
|
||||
.querySelectorAll( `[${ directivePrefix }island]` )
|
||||
.forEach( ( node ) => {
|
||||
if ( ! hydratedIslands.has( node ) ) {
|
||||
const fragment = createRootFragment(
|
||||
node.parentNode,
|
||||
node
|
||||
);
|
||||
const vdom = toVdom( node );
|
||||
hydrate( vdom, fragment );
|
||||
}
|
||||
} );
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
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 = () => {
|
||||
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 );
|
||||
};
|
||||
@@ -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 );
|
||||
},
|
||||
} );
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
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, {
|
||||
dangerouslySetInnerHTML: { __html: node.innerHTML },
|
||||
} );
|
||||
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 );
|
||||
}
|
||||
Reference in New Issue
Block a user