UNPKG

@wq/router

Version:

Respond to URL changes with locally generated pages and custom events

483 lines (426 loc) 12.5 kB
import * as ReduxFirstRouter from "redux-first-router"; import queryString from "query-string"; import { capitalCase } from "capital-case"; import { getStore } from "@wq/store"; const { connectRoutes, push, NOT_FOUND, ADD_ROUTES } = ReduxFirstRouter; const HTML = "@@HTML", RENDER = "RENDER", FIRST = "@@FIRST", DEFAULT = "@@DEFAULT", LAST = "@@LAST", CURRENT = "@@CURRENT", validOrder = { [FIRST]: true, [DEFAULT]: true, [LAST]: true, }; const defaultQuerySerializer = { parse(str) { return queryString.parse(str, { arrayFormat: "comma" }); }, stringify(obj) { return queryString.stringify(obj, { arrayFormat: "comma" }); }, }; // Exported module object var router = { config: { store: "main", tmpl404: "404", debug: false, getTemplateName: (name) => name, querySerializer: defaultQuerySerializer, }, routesMap: {}, routeInfoFn: [], contextProcessors: [], }; // Configuration router.init = function (config) { // Define baseurl (without trailing slash) if it is not / if (config && config.base_url) { router.base_url = config.base_url; } router.config = { ...router.config, ...config, }; // Configuration options: // Define `tmpl404` if there is not a template named '404' // Set `debug` to true to log template & context information // Set getTemplateName to change how route names are resolved. const { reducer: routeReducer, middleware, enhancer, initialDispatch, } = connectRoutes( {}, { querySerializer: router.config.querySerializer, initialDispatch: false, } ); router.store = getStore(router.config.store); router.store.addReducer("location", routeReducer); router.store.addReducer("context", contextReducer); router.store.addReducer("routeInfo", routeInfoReducer); router.store.addEnhancer(enhancer); router.store.addMiddleware(middleware); router.store.setThunkHandler(router.addThunk); router._initialDispatch = initialDispatch; }; router.start = function () { if (!router.config) { throw new Error("Initialize router first!"); } var orderedRoutes = {}; [FIRST, DEFAULT, LAST].forEach(function (order) { Object.entries(router.routesMap).forEach(([name, path]) => { if (path.order === order) { orderedRoutes[name] = path; } }); }); router.store.dispatch({ type: ADD_ROUTES, payload: { routes: orderedRoutes }, }); router._initialDispatch(); }; function contextReducer(context = {}, action) { if (action.type != RENDER && action.type != NOT_FOUND) { return context; } let current; if (action.type === RENDER) { current = action.payload; } else if (action.type === NOT_FOUND) { const routeInfo = _routeInfo(action.meta.location); current = { router_info: { ...routeInfo, template: router.config.tmpl404, }, rt: router.base_url, url: routeInfo.full_path, }; } return { ...context, [current.router_info.name]: current, [CURRENT]: current, }; } function routeInfoReducer(routeInfo = {}, action) { if (action.meta && action.meta.location) { const current = _routeInfo(action.meta.location); return { ...routeInfo, [current.name]: current, [CURRENT]: current, }; } else { return routeInfo; } } async function _generateContext(dispatch, getState, refresh = false) { const location = getState().location; var context = { router_info: _routeInfo(location), rt: router.base_url, }; for (var i = 0; i < router.contextProcessors.length; i++) { var fn = router.contextProcessors[i]; context = { ...context, ...((await fn(context)) || {}), }; } if (context[NOT_FOUND]) { context.router_info.template = router.config.tmpl404; context.url = context.router_info.full_path; } return router.render(context, refresh); } router.register = function ( path, nameOrContext, context, order = DEFAULT, thunk = null ) { var name; const newUsage = " Usage: router.register(path[, name[, contextFn]])"; if (!validOrder[order]) { // Assume old-style prevent() callback was passed throw new Error("prevent() no longer supported." + newUsage); } if (context) { if (typeof context !== "function") { throw new Error( "Unexpected " + context + " for contextFn." + newUsage ); } } else if (typeof nameOrContext === "function") { context = nameOrContext; nameOrContext = null; } if (nameOrContext) { name = nameOrContext; if (typeof name !== "string") { throw new Error( "Unexpected " + name + " for route name." + newUsage ); } } else { if (path.indexOf("/") > -1) { throw new Error( "router.register() now requires a route name if path contains /." + newUsage ); } // Assume there is a template with the same name name = path; } if (context && context.length > 1) { throw new Error( "contextFn should take a single argument (the existing context) and return a new context for merging." ); } function thunkFn(dispatch, getState, bag) { _generateContext(dispatch, getState); if (thunk) { thunk(dispatch, getState, bag); } } router.routesMap[name.toUpperCase()] = { path: _normalizePath(path), thunk: thunkFn, order, }; if (context) { router.addContextForRoute(name, context); } return name; }; router.registerFirst = function (path, name, context) { router.register(path, name, context, FIRST); }; router.registerLast = function (path, name, context) { router.register(path, name, context, LAST); }; router.addThunk = function (name, thunk) { router.routesMap[name] = { thunk, order: FIRST, }; }; router.addThunks = function (thunks, thisObj) { Object.entries(thunks).forEach(([name, thunk]) => { if (thisObj) { thunk = thunk.bind(thisObj); } router.addThunk(name, thunk); }); }; router.addContext = function (fn) { router.contextProcessors.push(fn); }; router.addContextForRoute = function (pathOrName, fn) { const name = _getRouteName(pathOrName); function contextForRoute(context) { if (context.router_info.name == name) { return fn(context); } else { return {}; } } router.addContext(contextForRoute); }; router.onShow = function () { throw new Error("router.onShow() is removed. Use a run() plugin instead"); }; router.addRoute = function () { throw new Error( "router.addRoute() is removed. Use a run() plugin instead" ); }; router.push = function (path) { push(path); }; router.render = function (context, refresh) { if (refresh) { if (refresh === true) { refresh = (context._refreshCount || 0) + 1; } context = { ...context, _refreshCount: refresh, }; } const { site_title } = router.config; let title = router.getContextTitle(context, context.router_info); if (site_title && title !== site_title) { title = `${title} - ${site_title}`; } if (window.document) window.document.title = title; return router.store.dispatch({ type: RENDER, payload: context, }); }; router.getContext = function () { const { context = {} } = router.store.getState(); return context[CURRENT]; }; // Re-render existing context router.refresh = function () { const context = router.getContext(); router.render(context, true); }; // Regenerate context, then re-render page router.reload = function () { const context = router.getContext(), refresh = (context._refreshCount || 0) + 1; return _generateContext( (action) => router.store.dispatch(action), () => router.store.getState(), refresh ); }; // Simple 404 page helper router.notFound = function () { return { [NOT_FOUND]: true, }; }; // Use when loading HTML from server router.rawHTML = function (html) { return { [HTML]: html, }; }; router.base_url = ""; router.addRouteInfo = function (fn) { router.routeInfoFn.push(fn); }; router.getRouteInfo = function (context, routeInfo) { const { router_info: ctxRouteInfo } = context; if (routeInfo) { if ( !ctxRouteInfo || ["name", "mode", "variant", "item_id"].some( (key) => ctxRouteInfo[key] != routeInfo[key] ) ) { return { ...routeInfo, pending: true, }; } else { return ctxRouteInfo; } } else { return NO_ROUTE_INFO; } }; const NO_ROUTE_INFO = { pending: true }; router.getContextTitle = function (context, routeInfo) { var title; if (routeInfo && !routeInfo.pending) { title = context.title || context.label; } if (!title && routeInfo) { title = router.getRouteTitle(routeInfo); } if (!title) { title = "Loading..."; } return title; }; router.getRouteTitle = function (routeInfo) { const { page_config = {}, mode, variant } = routeInfo, verbose_name = page_config.verbose_name || page_config.name || routeInfo.name, verbose_name_plural = page_config.verbose_name_plural || page_config.url || `${verbose_name}s`; let title, prefix = ""; if (mode === "list" && verbose_name === "outbox") { title = "outbox"; } else if (mode === "list") { title = verbose_name_plural; } else if (mode === "edit") { title = verbose_name; if (variant === "new") { prefix = "New "; } else { prefix = "Edit "; } } else if (mode && mode !== "detail") { title = `${verbose_name} - ${mode}`; } else { title = verbose_name; } if (title && title === title.toLowerCase()) { title = capitalCase(title); } return prefix + title; }; function _normalizePath(path) { path = path.replace("<slug>", ":slug"); return router.base_url + "/" + path; } function _getRouteName(pathOrName) { var name; if (router.routesMap[pathOrName.toUpperCase()]) { name = pathOrName; } else { Object.entries(router.routesMap).forEach(([rname, rpath]) => { if (_normalizePath(pathOrName) === rpath.path) { name = rname; } }); } if (!name) { throw new Error("Unrecognized route: " + pathOrName); } return name.toLowerCase(); } function _removeBase(pathname) { return pathname.replace(router.base_url + "/", ""); } var _lastRouteInfo = null; function _routeInfo(location) { const info = _computeRouteInfo(location); if (JSON.stringify(info) !== JSON.stringify(_lastRouteInfo)) { _lastRouteInfo = info; } return _lastRouteInfo; } function _computeRouteInfo(location) { if (location.current && location.prev) { location = { ...location.current, prev: location.prev, }; } var info = {}; info.name = location.type.toLowerCase(); info.template = router.config.getTemplateName(info.name); info.prev_path = _removeBase(location.prev.pathname); info.path = _removeBase(location.pathname); info.path_enc = escape(info.path); info.full_path = location.pathname + (location.search ? "?" + location.search : ""); info.full_path_enc = escape(info.full_path); info.params = location.query; info.slugs = location.payload; info.base_url = router.base_url; router.routeInfoFn.forEach((fn) => (info = fn(info))); return info; } export default router;