UNPKG

@frontity/tiny-router

Version:

A tiny router for Frontity projects

323 lines (291 loc) 11.7 kB
import TinyRouter, { Packages } from "../types"; import { warn, error, observe, batch } from "frontity"; import { isError, isRedirection } from "@frontity/source"; import { Derived } from "frontity/types"; import { Data } from "@frontity/source/types"; /** * This is an experimental function to be able to resolve the types of derived * state (Derived type) and Actions (Action type). It is not complete and only * works for this case, but it is something that if proven useful could be * exposed in "frontity/types". It is based on some tips of this talk: * https://www.youtube.com/watch?v=wNsKJMSqtAk. * * @param derivedOrAction - The definition of the action. * @returns The same value in JavaScript, but the resolved value in TypeScript. */ const resolved = <T extends (...args: any) => any>( derivedOrAction: T ): ReturnType<T> => derivedOrAction as any; /** * Set the URL. * * @param link - The URL that will replace the current one. It can be a path * like `/category/nature/`, a path that includes the page * `/category/nature/page/2` or the full URL `https://site.com/category/nature`. * * @param options - An optional configuration object that can contain: * - `method` "push" | "replace" (default: "push"). * * The method used in the action. "push" corresponds to window.history.pushState * and "replace" to window.history.replaceState. * * - `state` - An object that will be saved in window.history.state. This object * is recovered when the user go back and forward using the browser buttons. * * @example * ``` * const Link = ({ actions, children, link }) => { * const onClick = (event) => { * event.preventDefault(); * actions.router.set(link); * }; * * return ( * <a href={link} onClick={onClick}> * {children} * </a> * ); * }; * ``` * @returns Void. */ export const set: TinyRouter["actions"]["router"]["set"] = ({ state, actions, libraries }) => (link, options = {}): void => { // Normalize the link. if (libraries.source && libraries.source.normalize) link = libraries.source.normalize(link); // If the link hasn't changed, do nothing. if (state.router.link === link) return; // Clone the state that we are going to use for `window.history` because it // cannot contain proxies. const historyState = JSON.parse(JSON.stringify(options.state || {})); // If the data is a redirection, then we set the link to the location. // The redirections are stored in source.data just like any other data. const data = state.source?.get(link); if (data && data.isReady && isRedirection(data)) { if (data.isExternal) { window.replaceLocation(data.location); } else { // If the link is internal, we have to discard the domain. const { pathname, search, hash } = new URL( data.location, "https://dummy-domain.com" ); // If there is a link normalize, we have to use it. if (libraries.source && libraries.source.normalize) link = libraries.source.normalize(pathname + search + hash); else link = pathname + search + hash; } } // If we are in the client, update `window.history` and fetch the link. if (state.frontity.platform === "client") { if (!options.method || options.method === "push") window.history.pushState(historyState, "", link); else if (options.method === "replace") window.history.replaceState(historyState, "", link); else if (options.method !== "pop") { // Throw an error if another method is used. We support "pop" internally // for popstate events. error( `The method ${options.method} is not supported by actions.router.set.` ); } // If `autoFetch` is on, do the fetch. if (state.router.autoFetch) actions.source?.fetch(link); } // Finally, set the `state.router.link` property to the new value. batch(() => { state.router.previous = state.router.link; state.router.link = link; state.router.state = historyState; }); }; /** * Replace the value of `state.router.state` with the give object. * * This implementation also executes a `window.history.replaceState()` with that * object. * * @param historyState - The history state object. * @returns Void. */ export const updateState: TinyRouter["actions"]["router"]["updateState"] = ({ state }) => (historyState: Record<string, unknown>) => { // Clone the state to make sure we don't leak proxies. const cloned = JSON.parse(JSON.stringify(historyState)); state.router.state = cloned; window.history.replaceState(cloned, ""); }; /** * Initilization of the router. * * @param store - The Frontity store. */ export const init: TinyRouter["actions"]["router"]["init"] = ({ state, actions, libraries, }) => { if (state.frontity.platform === "server") { // Populate the router info with the initial path and page. state.router.link = libraries.source?.normalize ? libraries.source.normalize(state.frontity.initialLink) : state.frontity.initialLink; } else { // Wrap `window.replace.location` so we can mock it in the e2e tests. // This is required because `window.location` is protected by the browser // and can't be modified. window.replaceLocation = window.replaceLocation || window.location.replace.bind(window.location); // Observe the current data object. If it is ever a redirection, replace the // current link with the new one. observe(() => { const data = state.source?.get(state.router.link); if (data && isRedirection(data)) { // If the redirection is external, redirect to the full URL. if (data.isExternal) { window.replaceLocation(data.location); } else { // If the redirection is internal, use actions.router.set to switch // to the new redirection. actions.router.set(data.location, { // Use "replace" to keep browser history consistent. method: "replace", // Keep the same history.state that the old link had. We have to // stringfy and parse the object because window.history.replaceState() // does not accept a Proxy. state: state.router.state, }); } } }); // The link stored in `state.router.link` may be wrong if the server changes // it in some cases (see https://github.com/frontity/frontity/issues/623). // For that reason, it is replaced with the current link in the browser. // We should remove it once we have Frontity Hooks/Filters. // Get the browser URL to remove the Frontity options. const browserURL = new URL(location.href); Array.from(browserURL.searchParams.keys()).forEach((key) => { if (key.startsWith("frontity_")) browserURL.searchParams.delete(key); }); // Get the browser link. const browserLink = browserURL.pathname + browserURL.search + browserURL.hash; // Normalize it. const link = libraries.source?.normalize ? libraries.source.normalize(browserLink) : browserLink; // Add the state to the browser history and replace the link. window.history.replaceState( JSON.parse(JSON.stringify(state.router.state)), "", link ); // We have to compare the `initalLink` with `browserLink` because we have // normalized the `link` at this point and `initialLink` is not normalized. // We also need to sort it because some CDNs return the same response for // URLs with same queries in different order. // Finally, we need to skip this on HMR. const browserLinkUrl = new URL(browserLink, "https://dummy.com"); browserLinkUrl.searchParams.sort(); const search = browserLinkUrl.searchParams.toString() ? `?${browserLinkUrl.searchParams.toString()}` : ""; const sortedBrowserLink = `${browserLinkUrl.pathname}${search}${browserLinkUrl.hash}`; // Also compare the normalized `initialLink` with the normalized // `browserLink` to avoid creating an infinite loop. const normalizedInitialLink = libraries.source?.normalize ? libraries.source.normalize(state.frontity.initialLink) : state.frontity.initialLink; if ( sortedBrowserLink !== state.frontity.initialLink && link !== normalizedInitialLink && !state.frontity.hmr ) { if (state.source) { /** * Derived state pointing to the initial data object. * * @param store - The Frontity store. * @returns The initial data object. */ const initialDataObject: Derived<Packages, Data> = ({ state }) => state.source.get(state.frontity.initialLink); state.source.data[link] = resolved(initialDataObject); } // Update the value of `state.router.link`. state.router.link = link; } // Listen to changes in history. window.addEventListener("popstate", (event) => { if (event.state) { actions.router.set( location.pathname + location.search + location.hash, // We are casting types here because `pop` is used only internally, // therefore we don't want to expose it in the types for users. { method: "pop", state: event.state } as any ); } }); } }; /** * Implementation of the `beforeSSR()` Frontity action as used by the * tiny-router. * * @param ctx - The context of the Koa application. * * @returns Void. */ export const beforeSSR: TinyRouter["actions"]["router"]["beforeSSR"] = ({ state, actions }) => async ({ ctx }) => { // If autoFetch is disabled, there is nothing to do. if (!state.router.autoFetch) { return; } // Because Frontity is a modular framework, it could happen that a source // package like `@frontity/wp-source` has not been installed but the user is // trying to use autoFetch option, which requires it. if (!actions.source || !actions.source.fetch || !state.source.get) { warn( "You are trying to use autoFetch but no source package is installed." ); return; } // Fetch the current link. await actions.source.fetch(state.router.link); const data = state.source.get(state.router.link); // Check if the link has a redirection. if (data && isRedirection(data)) { // If the redirection is external, just redirect to the full URL here. if (data.isExternal) { ctx.status = data.redirectionStatus; ctx.redirect(data.location); return; } // Recover all the missing query params from the original URL. This is // required because we remove the query params that start with `frontity_`. const location = new URL(data.location, "https://dummy-domain.com"); ctx.URL.searchParams.forEach((value, key) => { if (!location.searchParams.has(key)) location.searchParams.append(key, value); }); // Set the correct status for the redirection. It could be a 301, 302, 307 // or 308. ctx.status = data.redirectionStatus; // 30X redirections can be relative so adding the Frontity URL is not // needed - see https://tools.ietf.org/html/rfc7231#page-68. const redirectionURL = location.pathname + location.search + location.hash; ctx.redirect(redirectionURL); return; } if (isError(data)) { // If there was an error, return the proper status. ctx.status = data.errorStatus; return; } };