UNPKG

@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
// 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