|
|
|
|
@@ -1,22 +1,15 @@
|
|
|
|
|
import { hydrate, render } from 'preact';
|
|
|
|
|
import { toVdom, hydratedIslands } from './vdom';
|
|
|
|
|
import { createRootFragment } from './utils';
|
|
|
|
|
import { directivePrefix } from './constants';
|
|
|
|
|
import { csnMetaTagItemprop, directivePrefix } from './constants';
|
|
|
|
|
|
|
|
|
|
// The cache of visited and prefetched pages.
|
|
|
|
|
// The root to render the vdom (document.body).
|
|
|
|
|
let rootFragment;
|
|
|
|
|
|
|
|
|
|
// The cache of visited and prefetched pages, stylesheets and scripts.
|
|
|
|
|
const pages = new Map();
|
|
|
|
|
|
|
|
|
|
// Keep the same root fragment for each interactive region node.
|
|
|
|
|
const regionRootFragments = new WeakMap();
|
|
|
|
|
const getRegionRootFragment = ( region ) => {
|
|
|
|
|
if ( ! regionRootFragments.has( region ) ) {
|
|
|
|
|
regionRootFragments.set(
|
|
|
|
|
region,
|
|
|
|
|
createRootFragment( region.parentElement, region )
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return regionRootFragments.get( region );
|
|
|
|
|
};
|
|
|
|
|
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.
|
|
|
|
|
@@ -25,32 +18,94 @@ const cleanUrl = ( url ) => {
|
|
|
|
|
return u.pathname + u.search;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Fetch a new page and convert it to a static virtual DOM.
|
|
|
|
|
const fetchPage = async ( url ) => {
|
|
|
|
|
let dom;
|
|
|
|
|
try {
|
|
|
|
|
const res = await window.fetch( url );
|
|
|
|
|
if ( res.status !== 200 ) return false;
|
|
|
|
|
const html = await res.text();
|
|
|
|
|
dom = new window.DOMParser().parseFromString( html, 'text/html' );
|
|
|
|
|
} catch ( e ) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// Helper to check if a page can do client-side navigation.
|
|
|
|
|
export const canDoClientSideNavigation = ( dom ) =>
|
|
|
|
|
dom
|
|
|
|
|
.querySelector( `meta[itemprop='${ csnMetaTagItemprop }']` )
|
|
|
|
|
?.getAttribute( 'content' ) === 'active';
|
|
|
|
|
|
|
|
|
|
return regionsToVdom( dom );
|
|
|
|
|
/**
|
|
|
|
|
* 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} 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<HTMLElement>>} - 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;
|
|
|
|
|
} );
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Return an object with VDOM trees of those HTML regions marked with a
|
|
|
|
|
// `navigation-id` directive.
|
|
|
|
|
const regionsToVdom = ( dom ) => {
|
|
|
|
|
const regions = {};
|
|
|
|
|
const attrName = `data-${ directivePrefix }-navigation-id`;
|
|
|
|
|
dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
|
|
|
|
|
const id = region.getAttribute( attrName );
|
|
|
|
|
regions[ id ] = toVdom( region );
|
|
|
|
|
} );
|
|
|
|
|
// 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 { regions };
|
|
|
|
|
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 ) => {
|
|
|
|
|
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 fetchAssets( dom );
|
|
|
|
|
return { head, body: toVdom( dom.body ) };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Prefetch a page. We store the promise to avoid triggering a second fetch for
|
|
|
|
|
@@ -62,23 +117,14 @@ export const prefetch = ( url ) => {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Render all interactive regions contained in the given page.
|
|
|
|
|
const renderRegions = ( page ) => {
|
|
|
|
|
const attrName = `data-${ directivePrefix }-navigation-id`;
|
|
|
|
|
document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
|
|
|
|
|
const id = region.getAttribute( attrName );
|
|
|
|
|
const fragment = getRegionRootFragment( region );
|
|
|
|
|
render( page.regions[ id ], fragment );
|
|
|
|
|
} );
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Navigate to a new page.
|
|
|
|
|
export const navigate = async ( href, { replace = false } = {} ) => {
|
|
|
|
|
const url = cleanUrl( href );
|
|
|
|
|
prefetch( url );
|
|
|
|
|
const page = await pages.get( url );
|
|
|
|
|
if ( page ) {
|
|
|
|
|
renderRegions( page );
|
|
|
|
|
document.head.replaceChildren( ...page.head );
|
|
|
|
|
render( page.body, rootFragment );
|
|
|
|
|
window.history[ replace ? 'replaceState' : 'pushState' ](
|
|
|
|
|
{},
|
|
|
|
|
'',
|
|
|
|
|
@@ -95,7 +141,8 @@ window.addEventListener( 'popstate', async () => {
|
|
|
|
|
const url = cleanUrl( window.location ); // Remove hash.
|
|
|
|
|
const page = pages.has( url ) && ( await pages.get( url ) );
|
|
|
|
|
if ( page ) {
|
|
|
|
|
renderRegions( page );
|
|
|
|
|
document.head.replaceChildren( ...page.head );
|
|
|
|
|
render( page.body, rootFragment );
|
|
|
|
|
} else {
|
|
|
|
|
window.location.reload();
|
|
|
|
|
}
|
|
|
|
|
@@ -103,19 +150,37 @@ window.addEventListener( 'popstate', async () => {
|
|
|
|
|
|
|
|
|
|
// Initialize the router with the initial DOM.
|
|
|
|
|
export const init = async () => {
|
|
|
|
|
document
|
|
|
|
|
.querySelectorAll( `[data-${ directivePrefix }-interactive]` )
|
|
|
|
|
.forEach( ( node ) => {
|
|
|
|
|
if ( ! hydratedIslands.has( node ) ) {
|
|
|
|
|
const fragment = getRegionRootFragment( node );
|
|
|
|
|
const vdom = toVdom( node );
|
|
|
|
|
hydrate( vdom, fragment );
|
|
|
|
|
}
|
|
|
|
|
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 );
|
|
|
|
|
|
|
|
|
|
// 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 );
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
// Cache the current regions.
|
|
|
|
|
pages.set(
|
|
|
|
|
cleanUrl( window.location ),
|
|
|
|
|
Promise.resolve( regionsToVdom( document ) )
|
|
|
|
|
);
|
|
|
|
|
const head = await fetchAssets( document );
|
|
|
|
|
pages.set(
|
|
|
|
|
cleanUrl( window.location ),
|
|
|
|
|
Promise.resolve( { body, head } )
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
document
|
|
|
|
|
.querySelectorAll( `[data-${ directivePrefix }-interactive]` )
|
|
|
|
|
.forEach( ( node ) => {
|
|
|
|
|
if ( ! hydratedIslands.has( node ) ) {
|
|
|
|
|
const fragment = createRootFragment(
|
|
|
|
|
node.parentNode,
|
|
|
|
|
node
|
|
|
|
|
);
|
|
|
|
|
const vdom = toVdom( node );
|
|
|
|
|
hydrate( vdom, fragment );
|
|
|
|
|
}
|
|
|
|
|
} );
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|