@wordpress/interactivity-router
Version:
Package that exposes state and actions from the `core/router` store, part of the Interactivity API.
424 lines (400 loc) • 15 kB
JavaScript
/* wp:polyfill */
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.state = exports.actions = void 0;
var _interactivity = require("@wordpress/interactivity");
var _styles = require("./assets/styles");
var _scriptModules = require("./assets/script-modules");
function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } /**
* WordPress dependencies
*/ /**
* Internal dependencies
*/
const {
directivePrefix,
getRegionRootFragment,
initialVdom,
toVdom,
render,
parseServerData,
populateServerData,
batch
} = (0, _interactivity.privateApis)('I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.');
const regionAttr = `data-${directivePrefix}-router-region`;
const interactiveAttr = `data-${directivePrefix}-interactive`;
const regionsSelector = `[${interactiveAttr}][${regionAttr}]:not([${interactiveAttr}] [${interactiveAttr}])`;
// The cache of visited and prefetched pages, stylesheets and scripts.
const pages = new Map();
// Helper to remove domain and hash from the URL. We are only interesting in
// caching the path and the query.
const getPagePath = url => {
const u = new URL(url, window.location.href);
return u.pathname + u.search;
};
/**
* Parses the given region's directive.
*
* @param region Region element.
* @return Data contained in the region directive value.
*/
const parseRegionAttribute = region => {
const value = region.getAttribute(regionAttr);
try {
const {
id,
attachTo
} = JSON.parse(value);
return {
id,
attachTo
};
} catch (e) {
return {
id: value
};
}
};
/**
* Fetches and prepares a page from a given URL.
*
* @param url The URL of the page to fetch.
* @param options Options for the fetch operation.
* @param options.html Optional HTML content. If provided, the function will use
* this instead of fetching from the URL.
* @return A Promise that resolves to the prepared page, or false if
* there was an error during fetching or preparation.
*/
const fetchPage = async (url, {
html
}) => {
try {
if (!html) {
const res = await window.fetch(url);
if (res.status !== 200) {
return false;
}
html = await res.text();
}
const dom = new window.DOMParser().parseFromString(html, 'text/html');
return await preparePage(url, dom);
} catch (e) {
return false;
}
};
/**
* Processes a DOM document to extract router regions and related resources.
*
* This function analyzes the provided DOM document and creates a virtual DOM
* representation of all HTML regions marked with a `router-region` directive.
* It also extracts and preloads associated styles and scripts to prepare for
* rendering the page.
*
* @param url The URL associated with the page, used for asset
* loading and caching.
* @param dom The DOM document to process.
* @param vdomParams Optional parameters for virtual DOM processing.
* @param vdomParams.vdom An optional existing virtual DOM cache to check for
* regions. If a region exists in this cache, it will be
* reused instead of creating a new vDOM representation.
* @return A Promise that resolves to a {@link Page} object
* containing the virtual DOM for all router regions,
* preloaded styles and scripts, page title, and initial
* server-rendered data.
*/
const preparePage = async (url, dom, {
vdom
} = {}) => {
// Remove all noscript elements as they're irrelevant when request is served via router.
// This prevents browsers from extracting styles from noscript tags.
dom.querySelectorAll('noscript').forEach(el => el.remove());
const regions = {};
const regionsToAttach = {};
dom.querySelectorAll(regionsSelector).forEach(region => {
const {
id,
attachTo
} = parseRegionAttribute(region);
regions[id] = vdom?.has(region) ? vdom.get(region) : toVdom(region);
if (attachTo) {
regionsToAttach[id] = attachTo;
}
});
const title = dom.querySelector('title')?.innerText;
const initialData = parseServerData(dom);
// Wait for styles and modules to be ready.
const [styles, scriptModules] = await Promise.all([Promise.all((0, _styles.preloadStyles)(dom, url)), Promise.all((0, _scriptModules.preloadScriptModules)(dom))]);
return {
regions,
regionsToAttach,
styles,
scriptModules,
title,
initialData,
url
};
};
/**
* Renders a page by applying styles, populating server data, rendering regions,
* and updating the document title.
*
* @param page The {@link Page} object to render.
*/
const renderPage = page => {
(0, _styles.applyStyles)(page.styles);
// Clone regionsToAttach.
const regionsToAttach = {
...page.regionsToAttach
};
batch(() => {
populateServerData(page.initialData);
document.querySelectorAll(regionsSelector).forEach(region => {
const {
id
} = parseRegionAttribute(region);
const fragment = getRegionRootFragment(region);
render(page.regions[id], fragment);
// If this is an attached region, remove it from the list.
delete regionsToAttach[id];
});
// Render unattached regions.
for (const id in regionsToAttach) {
const parent = document.querySelector(regionsToAttach[id]);
// Get the type from the vnode. If wrapped with Directives, get the
// original type from `props.type`.
const {
props,
type
} = page.regions[id];
const elementType = typeof type === 'function' ? props.type : type;
// Create an element with the obtained type where the region will be
// rendered. The type should match the one of the root vnode.
const region = document.createElement(elementType);
parent.appendChild(region);
const fragment = getRegionRootFragment(region);
render(page.regions[id], fragment);
}
});
if (page.title) {
document.title = page.title;
}
};
/**
* Loads the given page forcing a full page reload.
*
* The function returns a promise that won't resolve, useful to prevent any
* potential feedback indicating that the navigation has finished while the new
* page is being loaded.
*
* @param href The page href.
* @return Promise that never resolves.
*/
const forcePageReload = href => {
window.location.assign(href);
return new Promise(() => {});
};
// Listen to the back and forward buttons and restore the page if it's in the
// cache.
window.addEventListener('popstate', async () => {
const pagePath = getPagePath(window.location.href); // Remove hash.
const page = pages.has(pagePath) && (await pages.get(pagePath));
if (page) {
renderPage(page);
// Update the URL in the state.
state.url = window.location.href;
} else {
window.location.reload();
}
});
// Initialize the router and cache the initial page using the initial vDOM.
window.document.querySelectorAll('script[type=module][src]').forEach(({
src
}) => (0, _scriptModules.markScriptModuleAsResolved)(src));
pages.set(getPagePath(window.location.href), Promise.resolve(preparePage(getPagePath(window.location.href), document, {
vdom: initialVdom
})));
// Variable to store the current navigation.
let navigatingTo = '';
let hasLoadedNavigationTextsData = false;
const navigationTexts = {
loading: 'Loading page, please wait.',
loaded: 'Page Loaded.'
};
const {
state,
actions
} = (0, _interactivity.store)('core/router', {
state: {
url: window.location.href,
navigation: {
hasStarted: false,
hasFinished: false
}
},
actions: {
/**
* Navigates to the specified page.
*
* This function normalizes the passed href, fetches the page HTML if
* needed, and updates any interactive regions whose contents have
* changed. It also creates a new entry in the browser session history.
*
* @param href The page href.
* @param [options] Options object.
* @param [options.force] If true, it forces re-fetching the URL.
* @param [options.html] HTML string to be used instead of fetching the requested URL.
* @param [options.replace] If true, it replaces the current entry in the browser session history.
* @param [options.timeout] Time until the navigation is aborted, in milliseconds. Default is 10000.
* @param [options.loadingAnimation] Whether an animation should be shown while navigating. Default to `true`.
* @param [options.screenReaderAnnouncement] Whether a message for screen readers should be announced while navigating. Default to `true`.
*
* @return Promise that resolves once the navigation is completed or aborted.
*/
*navigate(href, options = {}) {
const {
clientNavigationDisabled
} = (0, _interactivity.getConfig)();
if (clientNavigationDisabled) {
yield forcePageReload(href);
}
const pagePath = getPagePath(href);
const {
navigation
} = state;
const {
loadingAnimation = true,
screenReaderAnnouncement = true,
timeout = 10000
} = options;
navigatingTo = href;
actions.prefetch(pagePath, options);
// Create a promise that resolves when the specified timeout ends.
// The timeout value is 10 seconds by default.
const timeoutPromise = new Promise(resolve => setTimeout(resolve, timeout));
// Don't update the navigation status immediately, wait 400 ms.
const loadingTimeout = setTimeout(() => {
if (navigatingTo !== href) {
return;
}
if (loadingAnimation) {
navigation.hasStarted = true;
navigation.hasFinished = false;
}
if (screenReaderAnnouncement) {
a11ySpeak('loading');
}
}, 400);
const page = yield Promise.race([pages.get(pagePath), timeoutPromise]);
// Dismiss loading message if it hasn't been added yet.
clearTimeout(loadingTimeout);
// Once the page is fetched, the destination URL could have changed
// (e.g., by clicking another link in the meantime). If so, bail
// out, and let the newer execution to update the HTML.
if (navigatingTo !== href) {
return;
}
if (page && !page.initialData?.config?.['core/router']?.clientNavigationDisabled) {
yield (0, _scriptModules.importScriptModules)(page.scriptModules);
renderPage(page);
window.history[options.replace ? 'replaceState' : 'pushState']({}, '', href);
// Update the URL in the state.
state.url = href;
// Update the navigation status once the the new page rendering
// has been completed.
if (loadingAnimation) {
navigation.hasStarted = false;
navigation.hasFinished = true;
}
if (screenReaderAnnouncement) {
a11ySpeak('loaded');
}
// Scroll to the anchor if exits in the link.
const {
hash
} = new URL(href, window.location.href);
if (hash) {
document.querySelector(hash)?.scrollIntoView();
}
} else {
yield forcePageReload(href);
}
},
/**
* Prefetches the page with the passed URL.
*
* The function normalizes the URL and stores internally the fetch
* promise, to avoid triggering a second fetch for an ongoing request.
*
* @param url The page URL.
* @param [options] Options object.
* @param [options.force] Force fetching the URL again.
* @param [options.html] HTML string to be used instead of fetching the requested URL.
*
* @return Promise that resolves once the page has been fetched.
*/
*prefetch(url, options = {}) {
const {
clientNavigationDisabled
} = (0, _interactivity.getConfig)();
if (clientNavigationDisabled) {
return;
}
const pagePath = getPagePath(url);
if (options.force || !pages.has(pagePath)) {
pages.set(pagePath, fetchPage(pagePath, {
html: options.html
}));
}
yield pages.get(pagePath);
}
}
});
/**
* Announces a message to screen readers.
*
* This is a wrapper around the `@wordpress/a11y` package's `speak` function. It handles importing
* the package on demand and should be used instead of calling `a11y.speak` directly.
*
* @param messageKey The message to be announced by assistive technologies.
*/
exports.actions = actions;
exports.state = state;
function a11ySpeak(messageKey) {
if (!hasLoadedNavigationTextsData) {
hasLoadedNavigationTextsData = true;
const content = document.getElementById('wp-script-module-data-@wordpress/interactivity-router')?.textContent;
if (content) {
try {
const parsed = JSON.parse(content);
if (typeof parsed?.i18n?.loading === 'string') {
navigationTexts.loading = parsed.i18n.loading;
}
if (typeof parsed?.i18n?.loaded === 'string') {
navigationTexts.loaded = parsed.i18n.loaded;
}
} catch {}
} else {
// Fallback to localized strings from Interactivity API state.
// @todo This block is for Core < 6.7.0. Remove when support is dropped.
// @ts-expect-error
if (state.navigation.texts?.loading) {
// @ts-expect-error
navigationTexts.loading = state.navigation.texts.loading;
}
// @ts-expect-error
if (state.navigation.texts?.loaded) {
// @ts-expect-error
navigationTexts.loaded = state.navigation.texts.loaded;
}
}
}
const message = navigationTexts[messageKey];
Promise.resolve().then(() => _interopRequireWildcard(require('@wordpress/a11y'))).then(({
speak
}) => speak(message),
// Ignore failures to load the a11y module.
() => {});
}
//# sourceMappingURL=index.js.map