@wordpress/interactivity-router
Version:
Package that exposes state and actions from the `core/router` store, part of the Interactivity API.
367 lines (366 loc) • 11.7 kB
JavaScript
// packages/interactivity-router/src/index.ts
import { store, privateApis, getConfig } from "@wordpress/interactivity";
import { preloadStyles, applyStyles } from "./assets/styles.mjs";
import {
preloadScriptModules,
importScriptModules,
markScriptModuleAsResolved
} from "./assets/script-modules.mjs";
var {
getRegionRootFragment,
initialVdomPromise,
toVdom,
render,
parseServerData,
populateServerData,
batch,
routerRegions,
h: createElement,
navigationSignal,
sessionId,
warn
} = privateApis(
"I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress."
);
var regionAttr = `data-wp-router-region`;
var interactiveAttr = `data-wp-interactive`;
var regionsSelector = `[${interactiveAttr}][${regionAttr}], [${interactiveAttr}] [${interactiveAttr}][${regionAttr}]`;
var pages = /* @__PURE__ */ new Map();
var getPagePath = (url) => {
const u = new URL(url, window.location.href);
return u.pathname + u.search;
};
var parseRegionAttribute = (region) => {
const value = region.getAttribute(regionAttr);
try {
const { id, attachTo } = JSON.parse(value);
return { id, attachTo };
} catch {
return { id: value };
}
};
var cloneRouterRegionContent = (vdom) => {
if (!vdom) {
return vdom;
}
const allPriorityLevels = vdom.props.priorityLevels;
const routerRegionLevel = allPriorityLevels.findIndex(
(level) => level.includes("router-region")
);
const priorityLevels = routerRegionLevel !== -1 ? allPriorityLevels.slice(routerRegionLevel + 1) : allPriorityLevels;
return priorityLevels.length > 0 ? createElement(vdom.type, {
...vdom.props,
priorityLevels
}) : vdom.props.element;
};
var regionsToAttachByParent = /* @__PURE__ */ new WeakMap();
var rootFragmentsByParent = /* @__PURE__ */ new WeakMap();
var initialRegionsToAttach = /* @__PURE__ */ new Set();
var 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 {
return false;
}
};
var preparePage = async (url, dom, { vdom } = {}) => {
dom.querySelectorAll("noscript").forEach((el) => el.remove());
const regions = {};
const regionsToAttach = {};
dom.querySelectorAll(regionsSelector).forEach((region) => {
const { id, attachTo } = parseRegionAttribute(region);
if (region.parentElement.closest(`[${regionAttr}]`)) {
regions[id] = void 0;
} else {
regions[id] = vdom?.has(region) ? vdom.get(region) : toVdom(region);
}
if (attachTo && !initialRegionsToAttach.has(id)) {
regionsToAttach[id] = attachTo;
}
});
const title = dom.querySelector("title")?.innerText;
const initialData = parseServerData(dom);
const [styles, scriptModules] = await Promise.all([
Promise.all(preloadStyles(dom)),
Promise.all(preloadScriptModules(dom))
]);
return {
regions,
regionsToAttach,
styles,
scriptModules,
title,
initialData,
url
};
};
var renderPage = (page) => {
applyStyles(page.styles);
const regionsToAttach = { ...page.regionsToAttach };
batch(() => {
populateServerData(page.initialData);
navigationSignal.value += 1;
routerRegions.forEach((signal) => {
signal.value = null;
});
const parentsToUpdate = /* @__PURE__ */ new Set();
for (const id in regionsToAttach) {
const parent = document.querySelector(regionsToAttach[id]);
if (!regionsToAttachByParent.has(parent)) {
regionsToAttachByParent.set(parent, []);
}
const regions = regionsToAttachByParent.get(parent);
if (!regions.includes(id)) {
regions.push(id);
parentsToUpdate.add(parent);
}
}
for (const id in page.regions) {
if (routerRegions.has(id)) {
routerRegions.get(id).value = cloneRouterRegionContent(
page.regions[id]
);
}
}
parentsToUpdate.forEach((parent) => {
const ids = regionsToAttachByParent.get(parent);
const vdoms = ids.map((id) => page.regions[id]);
if (!rootFragmentsByParent.has(parent)) {
const regions = vdoms.map(({ props, type }) => {
const elementType = typeof type === "function" ? props.type : type;
const region = document.createElement(elementType);
parent.appendChild(region);
return region;
});
rootFragmentsByParent.set(
parent,
getRegionRootFragment(regions)
);
}
const fragment = rootFragmentsByParent.get(parent);
render(vdoms, fragment);
});
});
if (page.title) {
document.title = page.title;
}
};
var forcePageReload = (href) => {
window.location.assign(href);
return new Promise(() => {
});
};
window.addEventListener("popstate", async () => {
const pagePath = getPagePath(window.location.href);
const page = pages.has(pagePath) && await pages.get(pagePath);
if (page) {
batch(() => {
state.url = window.location.href;
renderPage(page);
});
} else {
window.location.reload();
}
});
document.querySelectorAll(regionsSelector).forEach((region) => {
const { id, attachTo } = parseRegionAttribute(region);
if (attachTo) {
initialRegionsToAttach.add(id);
}
});
window.document.querySelectorAll("script[type=module][src]").forEach(({ src }) => markScriptModuleAsResolved(src));
(async () => {
const initialVdomMap = await initialVdomPromise;
pages.set(
getPagePath(window.location.href),
Promise.resolve(
preparePage(getPagePath(window.location.href), document, {
vdom: initialVdomMap
})
)
);
})();
var navigatingTo = "";
var hasLoadedNavigationTextsData = false;
var navigationTexts = {
loading: "Loading page, please wait.",
loaded: "Page Loaded."
};
var { state: privateState } = store(
"core/router/private",
{
state: {
navigation: {
hasStarted: false,
hasFinished: false
}
}
},
{ lock: true }
);
var { state, actions } = store("core/router", {
state: {
get navigation() {
if (globalThis.SCRIPT_DEBUG) {
warn(
`The usage of state.navigation.{hasStarted|hasFinished} from core/router is deprecated and will stop working in WordPress 7.1.`
);
}
return privateState.navigation;
}
},
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 } = getConfig();
if (clientNavigationDisabled) {
yield forcePageReload(href);
}
const pagePath = getPagePath(href);
const { navigation } = privateState;
const {
loadingAnimation = true,
screenReaderAnnouncement = true,
timeout = 1e4
} = options;
navigatingTo = href;
actions.prefetch(pagePath, options);
const timeoutPromise = new Promise(
(resolve) => setTimeout(resolve, timeout)
);
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
]);
clearTimeout(loadingTimeout);
if (navigatingTo !== href) {
return;
}
if (page && !page.initialData?.config?.["core/router"]?.clientNavigationDisabled) {
yield importScriptModules(page.scriptModules);
batch(() => {
state.url = href;
if (loadingAnimation) {
navigation.hasStarted = false;
navigation.hasFinished = true;
}
renderPage(page);
});
window.history[options.replace ? "replaceState" : "pushState"]({ wpInteractivityId: sessionId }, "", href);
if (screenReaderAnnouncement) {
a11ySpeak("loaded");
}
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 } = 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);
}
}
});
state.url = state.url || window.location.href;
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 {
if (state.navigation.texts?.loading) {
navigationTexts.loading = state.navigation.texts.loading;
}
if (state.navigation.texts?.loaded) {
navigationTexts.loaded = state.navigation.texts.loaded;
}
}
}
const message = navigationTexts[messageKey];
import("@wordpress/a11y").then(
({ speak }) => speak(message),
// Ignore failures to load the a11y module.
() => {
}
);
}
export {
actions,
state
};
//# sourceMappingURL=index.mjs.map