UNPKG

single-page-express

Version:

📄 A client-side implementation of the Express route API.

775 lines (689 loc) • 38.9 kB
const pathToRegexpMatch = require('path-to-regexp').match // route parser for express 5+ const pathToRegexpMatchExpress4 = require('path-to-regexp-express4') // route parser for express 4; express 3 and below are not supported const parser = new window.DOMParser() // used by the default render method function singlePageExpress (options) { // #region constructor params and top-level variable declarations const app = {} // instance of the router app app.expressVersion = options.expressVersion // which version of the express api to target if (app.expressVersion !== 5 && (parseInt(app.expressVersion) <= 4)) app.expressVersion = 4 // permit express 4 and 5+ if (!app.expressVersion) app.expressVersion = 5 // default to express 5 app.appVars = {} // for app.set() / app.get() app.templatingEngine = options.templatingEngine // which templating engine to use app.templates = options.templates // templates to render if (!app.templates) console.warn('single-page-express: no templates are loaded; as such the default render method will just print the template name and model to the console.') app.routes = {} // list of functions to execute when trying to see if this route matches one of the known patterns indexed by original route method#string app.routeCallbacks = {} // list of functions to execute when the route is invoked app.defaultTarget = options.defaultTarget // which element to replace by default app.defaultTargets = app.defaultTarget ? [app.defaultTarget].concat(options.defaultTargets || []) : options.defaultTargets || [] // which elements to replace by default if (!app.defaultTargets.length) app.defaultTargets = ['body'] // body tag is the default target if none is set app.beforeEveryRender = options.beforeEveryRender // function to execute before every DOM update if using the default render method app.updateDelay = options.updateDelay // how long to delay after executing app.beforeEveryRender or this.beforeRender before performing the DOM update app.afterEveryRender = options.afterEveryRender // function to execute after every DOM update if using the default render method app.postRenderCallbacks = options.postRenderCallbacks || {} // list of callback functions to execute after a render event occurs app.topbarEnabled = !options.disableTopbar // whether to use topbar https://buunguyen.github.io/topbar/ app.topBarRoutes = options.topBarRoutes // which routes to use the topbar on; defaults to all if this option is not supplied if (app.topbarEnabled || app.topBarRoutes) { app.topbar = require('topbar') app.topbar.config(options.topbarConfig || { // default options barColors: { 0: 'rgba(0, 0, 0, .7)', '1.0': 'rgba(0, 0, 0, .7)' } }) } app.alwaysScrollTop = options.alwaysScrollTop // always scroll to the top of the page after every render app.urls = {} // list of URLs that have been visited and metadata about them let currentViewTransition // a global reference to the current view transition so we can know when it has ended // taken from https://expressjs.com/en/api.html#routing-methods const httpVerbs = [ 'checkout', 'copy', 'delete', 'get', 'head', 'lock', 'merge', 'mkactivity', 'mkcol', 'move', 'm-search', 'notify', 'options', 'patch', 'post', 'purge', 'put', 'report', 'search', 'subscribe', 'trace', 'unlock', 'unsubscribe' ] // #endregion // #region express app // express app object settings app.appVars['case sensitive routing'] = false app.appVars.env = 'production' app.appVars['query parser'] = true app.appVars['strict routing'] = false app.appVars['subdomain offset'] = 2 // the other settings are not supported // express app object properties app.locals = {} // stubbed out app.mountpath = '' // stubbed out // express app object events app.mount = () => {} // stubbed out // registers a route and handles middleware function routeHandler (method, middleware, route, callback) { let callbackWithMiddlewareExecutingFirst = middleware if (callback) callbackWithMiddlewareExecutingFirst = (req, res) => middleware(req, res, () => callback(req, res)) registerRoute(method, route, callbackWithMiddlewareExecutingFirst) } // express app object methods app.all = (middleware, route, callback) => { routeHandler('all', middleware, route, callback) } app.delete = (middleware, route, callback) => { routeHandler('delete', middleware, route, callback) } app.disable = (name) => { app.appVars[name] = false } app.disabled = (name) => { return !app.appVars[name] } app.enable = (name) => { app.appVars[name] = true } app.enabled = (name) => { return !!app.appVars[name] } app.engine = () => {} // stubbed out app.get = (middleware, name, callback) => { // in the express docs, this method is overloaded and can be used for more than one thing based on the number of arguments if (!callback) return app.appVars[name] else return routeHandler('get', middleware, name, callback) } app.listen = () => {} // stubbed out httpVerbs.forEach(method => { // app.METHOD // some method names are overloaded and can be used for more than one thing based on the number of arguments if (!app[method]) app[method] = (middleware, route, callback) => routeHandler(method, middleware, route, callback) }) app.param = () => {} // stubbed out app.path = () => {} // stubbed out app.post = (middleware, route, callback) => { routeHandler('post', middleware, route, callback) } app.put = (middleware, route, callback) => { routeHandler('put', middleware, route, callback) } // app.render will be defined below app.route = (route) => { const ret = { route } httpVerbs.forEach(method => { ret[method] = (middleware, callback) => { routeHandler(method, middleware, route, callback) } }) return ret } app.set = (name, val) => { app.appVars[name] = val } app.use = () => {} // stubbed out app.triggerRoute = handleRoute // single-page-express-exclusive method // #endregion // #region request object const defaultReq = {} // this is later extended during a "request" cycle // request object properties defaultReq.app = app defaultReq.baseUrl = '' // stubbed out // req.body is defined at runtime below // req.cookies is defined at runtime below defaultReq.fresh = true // stubbed out defaultReq.hostname = window.location.hostname defaultReq.ip = '127.0.0.1' // stubbed out defaultReq.ips = [] // stubbed out // req.method is defined at runtime below // req.originalUrl is defined at runtime below // req.params is defined at runtime below // req.path is defined at runtime below // req.protocol is defined at runtime below // req.query is defined at runtime below // req.res is defined below because res is not initialized yet // req.route is defined at runtime below // req.secure is defined at runtime below defaultReq.signedCookies = {} // stubbed out defaultReq.stale = false // stubbed out // req.subdomains is defined at runtime below defaultReq.xhr = true // stubbed out // request object methods defaultReq.accepts = () => {} // stubbed out defaultReq.acceptsCharsets = () => {} // stubbed out defaultReq.acceptsEncodings = () => {} // stubbed out defaultReq.acceptsLanguages = () => {} // stubbed out defaultReq.get = () => {} // stubbed out defaultReq.is = () => {} // stubbed out defaultReq.param = () => {} // stubbed out defaultReq.range = () => {} // stubbed out // new properties defaultReq.singlePageExpress = true // #endregion // #region response object const res = {} // response object properties res.app = app res.headersSent = false // stubbed out res.locals = {} // stubbed out // res.req defined at runtime below // response object methods res.append = () => { return res } // stubbed out res.attachment = () => { return res } // stubbed out res.cookie = (name, value, options = {}) => { const { domain, encode = encodeURIComponent, expires, httpOnly, maxAge, path = '/', partitioned, priority, secure, signed, sameSite } = options let cookieString = `${encode(name)}=${encode(value)}` if (expires instanceof Date) cookieString += `; expires=${expires.toUTCString()}` if (maxAge) cookieString += `; max-age=${maxAge}` if (domain) cookieString += `; domain=${domain}` if (path) cookieString += `; path=${path}` if (secure) cookieString += '; secure' if (httpOnly) cookieString += '; HttpOnly' if (sameSite) cookieString += `; SameSite=${sameSite}` if (partitioned) cookieString += '; Partitioned' if (priority) cookieString += `; Priority=${priority}` if (signed) { if (app.appVars.env === 'development') console.warn('Signed cookies are not supported in the browser context.') } document.cookie = cookieString } res.clearCookie = (name, options = {}) => { const { domain, encode = encodeURIComponent, httpOnly, path = '/', partitioned, priority, secure, signed, sameSite } = options const pastDate = new Date(0).toUTCString() // set the cookie's expiration date to a past date let cookieString = `${encode(name)}=; expires=${pastDate}` if (domain) cookieString += `; domain=${domain}` if (path) cookieString += `; path=${path}` if (secure) cookieString += '; secure' if (httpOnly) cookieString += '; HttpOnly' if (sameSite) cookieString += `; SameSite=${sameSite}` if (partitioned) cookieString += '; Partitioned' if (priority) cookieString += `; Priority=${priority}` if (signed) console.warn('Signed cookies are not supported in the frontend.') document.cookie = cookieString } res.download = () => { return res } // stubbed out res.end = () => { return res } // stubbed out res.format = () => { return res } // stubbed out res.get = () => { return res } // stubbed out res.json = (json) => { console.log(json) return res } res.jsonp = () => { return res } // stubbed out res.links = () => { return res } // stubbed out res.location = () => { return res } // stubbed out res.redirect = (status, route) => { if (!route) route = status handleRoute({ route }) return res } // res.render is defined below res.send = () => { return res } // stubbed out res.sendFile = () => { return res } // stubbed out res.sendStatus = () => { return res } // stubbed out res.set = () => { return res } // stubbed out res.status = () => { return res } // stubbed out res.type = () => { return res } // stubbed out res.vary = () => { return res } // stubbed out defaultReq.res = res // apply the response object to the default request object // #endregion // #region single-page-express methods // add a route to the route list function registerRoute (method, route, callback) { // if the method is 'all' then we need to call this function for every method if (method === 'all') { httpVerbs.forEach((middleware, method) => { routeHandler(method, middleware, route, callback) }) return } // if the function receives one argument, then route is the callback, and the actual route needs to be defined from `this` if (typeof route !== 'string' && typeof route === 'function') { callback = route route = this.route } // flatten if case insensitivity is enabled if (app.appVars['case sensitive routing']) route = route.toLowerCase() // remove trailing `/` if it exists if strict routing is disabled if (!app.appVars['strict routing'] && route.endsWith('/') && route !== '/') route = route.slice(0, -1) // check if route is already registered if (!(route in app.routes)) { // determine which route matching method to use let matcher if (app.expressVersion === 5) { try { matcher = pathToRegexpMatch(route) // the newer version of path-to-regexp returns a matching function } catch (error) { console.error(`single-page-express: failed to register the route '${route}' because it could not be parsed.`) if (route.includes('*')) console.error('single-page-express: routes with \'*\' in them should be written like \'*all\' instead in Express 5+ syntax.') console.error(error) } } else matcher = pathToRegexpMatchExpress4(route) // the older version of path-to-regexp returns a matching regular expression // register the route app.routes[`${method}#${route}`] = { method, route, matcher } app.routeCallbacks[`${method}#${route}`] = callback } } // if it's a registered route, fire its event; if it's not, let the browser handle it natively async function handleRoute (params) { let route = params.route const method = params.method ? ('' + params.method).toLowerCase() : 'get' // http method from the request // check if it's a registered route let match let routeWithoutQuery = route.split('?')[0] // TODO: handle links without href attributes // flatten if case insensitivity is enabled if (app.appVars['case sensitive routing']) routeWithoutQuery = routeWithoutQuery.toLowerCase() // remove trailing `/` if it exists and if strict routing is disabled if (!app.appVars['strict routing'] && routeWithoutQuery.length > 1 && routeWithoutQuery.endsWith('/')) routeWithoutQuery = routeWithoutQuery.slice(0, -1) // loop through route matcher functions to see if any of them match this url pattern for (const registeredRoute in app.routes) { const potentialMatch = app.routes[registeredRoute] if (potentialMatch.method !== method) continue // the registered route's declared method must match the request method if (app.expressVersion === 5) potentialMatch.data = potentialMatch.matcher(routeWithoutQuery) // the newer version of path-to-regexp returns a matching function else potentialMatch.data = potentialMatch.matcher.exec(routeWithoutQuery) // the older version of path-to-regexp returns a matching regular expression if (potentialMatch.data) { match = potentialMatch break } } if (match) { // it's a registered route, so hijack the event params.event?.preventDefault() // show top bar if ((app.topbarEnabled && !app.topBarRoutes) || app.topBarRoutes?.includes?.(match.route)) app.topbar.show() // save scroll position of current page before moving to the next page app.urls[window.location.pathname] = { scrollX: window.scrollX, scrollY: window.scrollY, scrollingChildContainers: {} } // save scroll position of child containers that scroll too, so long as they have ids for (const scrollingChildContainer of document.querySelectorAll('[id]')) { if (scrollingChildContainer.scrollHeight > scrollingChildContainer.clientHeight || scrollingChildContainer.scrollWidth > scrollingChildContainer.clientWidth) { app.urls[window.location.pathname].scrollingChildContainers[scrollingChildContainer.id] = { scrollX: scrollingChildContainer.scrollLeft, scrollY: scrollingChildContainer.scrollTop } } } // alter browser history state if (method === 'get' && !params.skipHistory) { const state = { index: historyStack.length } historyStack.push(state) currentIndex = historyStack.length - 1 window.history.pushState(state, '', route) } // build request object const req = { ...defaultReq } // req.body if (params.parseBody || params.body) { if (params.event?.target) { // it's possible to submit the form using app.triggerRoute, in which case there won't be form data req.body = Object.fromEntries(new FormData(params.event.target).entries()) // convert the form entries into key/value pairs if (params.event.submitter) req.body[params.event.submitter.name] = params.event.submitter.value // add which button was clicked to req.body } else if (params.body) req.body = params.body // use manually submitted request body if it is provided instead else req.body = {} // otherwise set req.body to an empty object if (app.expressVersion > 4 && req.body && Object.keys(req.body).length === 0) req.body = undefined // if req.body is an empty object and the express version is 5+ then set req.body to undefined to match the express api } // req.cookies const cookies = document.cookie.split('; ') req.cookies = {} cookies.forEach(cookie => { const [name, value] = cookie.split('=') req.cookies[decodeURIComponent(name)] = decodeURIComponent(value) }) req.method = method req.originalUrl = route // req.params if (app.expressVersion === 5) req.params = match.data.params // the newer version of path-to-regexp just gives us the params else { // the older version of path-to-regexp does not map the params to key/value pairs, so we have to do it ourselves req.params = {} const keys = match.route.match(/:([^/]+)/g)?.map(key => key.substring(1)) // extract the keys from the route pattern, if any exist if (keys) { const vals = match.matcher.exec(match.data[0]) // use the matcher to extract values from the data if (vals) for (const [index, key] of keys.entries()) req.params[key] = vals[index + 1] // make an object with key/value pairs, if any params exist } } // req.path and req.protocol const parsedUrl = new URL(window.location.href) req.path = parsedUrl.pathname req.protocol = parsedUrl.protocol // req.query if (app.appVars['query parser']) { const parts = route.split('?') // split the route by question marks route = parts[0] // the first part is the route const queryString = parts.slice(1).join(', ') // all the remaining parts are the query params req.query = Object.fromEntries(new URLSearchParams(queryString).entries()) // convert the query string into key/value pairs } req.route = match.data req.secure = req.protocol === 'https' || req.protocol === 'https:' // req.subdomains const parts = req.hostname.split('.') const subdomains = parts.slice(0, -parseInt(app.appVars['subdomain offset'])) req.subdomains = subdomains.reverse() // attach req object to res res.req = req // pass along whether the back button or forward button was pressed req.backButtonPressed = params.backButtonPressed req.forwardButtonPressed = params.forwardButtonPressed // add back/forward button classes to the html element const htmlEl = document.querySelector('html') if (params.backButtonPressed) { htmlEl.classList.add('backButtonPressed') htmlEl.classList.remove('forwardButtonPressed') } else if (params.forwardButtonPressed) { htmlEl.classList.remove('backButtonPressed') htmlEl.classList.add('forwardButtonPressed') } else { htmlEl.classList.remove('backButtonPressed') htmlEl.classList.remove('forwardButtonPressed') } // fire the event await app.routeCallbacks[`${method}#${match.route}`](req, res) // scroll the page appropriately const scrollPage = () => { // if this page has never been visited before or res.resetScroll or app.alwaysScrollTop is present if (!app.urls[route] || res.resetScroll || app.alwaysScrollTop) { window.scrollTo(0, 0) // scroll to the top if (res.resetScroll) { delete app.urls[route].scrollX delete app.urls[route].scrollY delete app.urls[route].scrollingChildContainers } } else if (app.urls[route]) { // if this page has been visited before window.scrollTo(app.urls[route].scrollX || 0, app.urls[route].scrollY || 0) // restore the previous scroll position // restore the position of scrollable containers for (const scrollingChildContainer in app.urls[route].scrollingChildContainers) { if (document.getElementById(scrollingChildContainer)) { document.getElementById(scrollingChildContainer).scrollTo(app.urls[route].scrollingChildContainers[scrollingChildContainer].scrollX || 0, app.urls[route].scrollingChildContainers[scrollingChildContainer].scrollY || 0) } } } res.resetScroll = null // clear this var so it does not persist on the next request; allow routes to opt-in // hide top bar (loading completed) if ((app.topbarEnabled && !app.topBarRoutes) || app.topBarRoutes?.includes?.(match.route)) app.topbar.hide() } if (currentViewTransition) document.addEventListener('animationend', scrollPage) // scroll the page after view transitions or css animations are done else window.setTimeout(scrollPage, parseInt(res.updateDelay) || parseInt(app.updateDelay) || 0) // scroll page after user-defined animation finishes; delay the scroll until after the render by using the same delay mechanism as the default render method } } // app.render implements the express api on the surface, then prescribes some default behavior specific to this module, provides a default method for dom manipulation, and allows for a user to override the default dom manipulation behaviors app.render = function (template, model, callback) { model = model || {} // clear all `this` variables so they do not persist but store local copies for this method invocation's use const thisTitle = this.title const thisBeforeRender = this.beforeRender const thisTarget = this.target const thisAppendTargets = this.appendTargets const thisFocus = this.focus const thisRemoveMetaTags = this.removeMetaTags const thisRemoveStyleTags = this.removeStyleTags const thisRemoveLinkTags = this.removeLinkTags const thisRemoveScriptTags = this.removeScriptTags const thisRemoveBaseTags = this.removeBaseTags const thisRemoveTemplateTags = this.removeTemplateTags const thisRemoveHeadTags = this.removeHeadTags const thisUpdateDelay = this.updateDelay const thisAfterRender = this.afterRender this.title = null this.beforeRender = null this.target = null this.focus = null this.removeMetaTags = null this.removeStyleTags = null this.removeLinkTags = null this.removeScriptTags = null this.removeBaseTags = null this.removeTemplateTags = null this.removeHeadTags = null this.updateDelay = null this.afterRender = null const postRenderCallbacks = () => { // fire post-render callback for this template if it exists if (app.postRenderCallbacks[template]) { if (typeof app.postRenderCallbacks[template] === 'function') { app.postRenderCallbacks[template](model) } else console.error(`single-page-express: post-render callback for ${template} is not a function.`) } // fire a post-render callback registered for all templates if it exists for (const key in app.postRenderCallbacks) { if (key.startsWith('*')) { // this allows both * and *all syntax for both express 4 and 5 compatibility if (typeof app.postRenderCallbacks[key] === 'function') app.postRenderCallbacks[key](model) else console.error(`single-page-express: post-render callback for ${key} is not a function.`) break } } } if (options.renderMethod) { // execute user-supplied render method if it is provided options.renderMethod(template, model, callback) postRenderCallbacks() } else { // execute default render method if the user does not supply one let err // if no templates exist at all, log the render method arguments to the console and display a warning that no templates are loaded if (!err && !app.templates) { err = 'single-page-express: no templates are loaded.' console.log('template:', template) console.log('model:', model) } if (!err && (!app?.templatingEngine.render || typeof app?.templatingEngine?.render !== 'function')) { err = 'single-page-express: no template engine is loaded or the engine supplied does not have a `render` method; please use a templating engine that is compatible with Express' console.error(err) } if (!err && !app.templates[template]) { err = `single-page-express: attempted to render template which does not exist: ${template}` console.error(err) } let markup = '' if (!err) { // render the template with the chosen templating system try { markup = app.templatingEngine.render(template, model) // TODO: leverage https://html-validate.org/ — will need to be a peer dep // add html-validate to devDependencies // const htmlValidate = require('./node_modules/html-validate/dist/cjs/browser.js') // console.log(htmlValidate) // this seems to crash webpack for some reason } catch (error) { const msg = `single-page-express: error parsing post-rendered template: ${template}` console.error(msg) console.error(error.message) err = msg + '\n' + error.message } if (!err) { // build a dom from the rendered markup let doc try { doc = parser.parseFromString(markup, 'text/html') } catch (error) { const msg = `single-page-express: error parsing post-rendered template: ${template}` console.error(msg) console.error(error.message) err = msg + '\n' + error.message } if (!err) { // replace title tag with the new one if (thisTitle) { // check if res.title is set if (document.querySelector('title')) { // check if the title element exists document.querySelector('title').innerHTML = thisTitle // replace the page title with the new title from res.title } } else if (doc.querySelector('title') && document.querySelector('title')) { // otherwise check if a <title> tag exists in the template document.querySelector('title').innerHTML = doc.querySelector('title').innerHTML // if so, replace the page title with the new title from the <title> tag } // determine the targets let targets if (thisTarget) { if (Array.isArray(thisTarget)) targets = thisAppendTargets ? app.defaultTargets.concat(thisTarget) : thisTarget else targets = thisAppendTargets ? app.defaultTargets.concat([thisTarget]) : [thisTarget] } else targets = app.defaultTargets // call beforeRender methods if they exist const beforeAfterRenderArg = { model, markup, doc, targets } if (app.beforeEveryRender && typeof app.beforeEveryRender === 'function') app.beforeEveryRender(beforeAfterRenderArg) // call app.beforeEveryRender function if it exists if (thisBeforeRender && typeof thisBeforeRender === 'function') thisBeforeRender(beforeAfterRenderArg) // call res.beforeRender function if it exists // remove tags from the head tag if any res.remove* properties are set if (thisRemoveMetaTags) for (const tag of document.querySelectorAll('head meta')) tag.remove() // res.removeMetaTags if (thisRemoveStyleTags) for (const tag of document.querySelectorAll('head style')) tag.remove() // res.removeStyleTags if (thisRemoveLinkTags) for (const tag of document.querySelectorAll('head link')) tag.remove() // res.removeLinkTags if (thisRemoveScriptTags) for (const tag of document.querySelectorAll('head script')) tag.remove() // res.removeScriptTags if (thisRemoveBaseTags) for (const tag of document.querySelectorAll('head base')) tag.remove() // res.removeBaseTags if (thisRemoveTemplateTags) for (const tag of document.querySelectorAll('head template')) tag.remove() // res.removeTemplateTags if (thisRemoveHeadTags) for (const tag of document.querySelectorAll('head > :not(title)')) tag.remove() // res.removeHeadTags // update the attributes of the html tag and head tag; preexisting attributes will not be removed; only new ones added or old ones updated for (const attrib of doc.documentElement.attributes) document.documentElement.setAttribute(attrib.name, attrib.value) for (const attrib of doc.head.attributes) document.head.setAttribute(attrib.name, attrib.value) // add any new tags to the head tag from the new page that aren't present in the previous page const oldHeadElements = Array.from(document.head.children) const newHeadElements = Array.from(doc.head.children) const oldHeadElementsStrings = oldHeadElements.map(el => el.outerHTML) // for comparison const diffElements = newHeadElements.filter(el => !oldHeadElementsStrings.includes(el.outerHTML)) // figure out which head elements are new // wait until link tags finish loading before updating the DOM to prevent a FOUC https://en.wikipedia.org/wiki/Flash_of_unstyled_content const linkTagsInDiff = diffElements.filter(el => el.tagName.toLowerCase() === 'link') const loadPromises = [] for (const linkTag of linkTagsInDiff) loadPromises.push(new Promise((resolve) => { linkTag.addEventListener('load', () => resolve()) })) // wait until script tags finish loading before updating the DOM to prevent a FOUC https://en.wikipedia.org/wiki/Flash_of_unstyled_content for (const tag of diffElements) { // if the script tag is for a new script, don't update the DOM until it finishes loading if (tag.nodeName === 'SCRIPT' && !document.querySelector(`script[src="${tag.src}"]`) && !document.querySelector(`script[src="${tag.src.replace(window.location.origin, '')}"]`)) { const script = document.createElement('script') script.src = tag.src script.type = 'text/javascript' script.async = true loadPromises.push(new Promise((resolve) => { script.onload = () => resolve() })) document.head.appendChild(script) } else document.head.appendChild(tag) // if it's a script we've already seen before, we don't need to wait for it } // update DOM after all link tags and script tags have finished loading Promise.all(loadPromises).then(() => { window.setTimeout(() => { const domUpdate = () => { for (const target of targets) { let targetEl if (document.querySelector(target)) { // check if the target is a valid DOM element targetEl = document.querySelector(target) const propertyToUpdate = targetEl.nodeName === 'BODY' ? 'innerHTML' : 'outerHTML' // if targetEl is a body tag, update innerHTML, otherwise outerHTML; this prevents duplicate head tags from being inserted into the DOM if (doc.querySelector(target)) { // if the new template has an element with the same id as the target container, then that's the container we're writing to targetEl[propertyToUpdate] = doc.querySelector(target).outerHTML // replace the target with the contents of the template's target id } else if (doc.body) { targetEl[propertyToUpdate] = doc.body.innerHTML // replace the target with the contents of body from the template } else { targetEl[propertyToUpdate] = doc.innerHTML // replace the target with the contents of the entire template } // announce the page change to screen readers const announcementContentElement = document.querySelector('[data-page-title]') || document.querySelector('h1[aria-label]') || document.querySelector('h1') || document.querySelector('title') if (!document.getElementById('singlePageExpressDefaultRenderMethodAriaLiveRegion')) { const liveRegion = document.createElement('p') liveRegion.id = 'singlePageExpressDefaultRenderMethodAriaLiveRegion' liveRegion.setAttribute('aria-live', 'assertive') liveRegion.setAttribute('aria-atomic', 'true') liveRegion.style.position = 'absolute' liveRegion.style.top = '-9999px' liveRegion.style.left = '-9999px' liveRegion.style.width = '1px' liveRegion.style.height = '1px' liveRegion.style.overflow = 'hidden' liveRegion.style.border = '0' liveRegion.style.margin = '-1px' liveRegion.style.padding = '0' liveRegion.style.clipPath = 'inset(50%)' liveRegion.style.whiteSpace = 'nowrap' document.body.appendChild(liveRegion) } document.getElementById('singlePageExpressDefaultRenderMethodAriaLiveRegion').textContent = '' // clear before announcing document.getElementById('singlePageExpressDefaultRenderMethodAriaLiveRegion').textContent = announcementContentElement.textContent // set browser focus const validElementsForOutline = ['A', 'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'FIELDSET'] // list of outlines that are okay to have a visible outline (mostly a problem in just safari; other browsers' default styles don't apply outlines to literally everything that is `focus()`ed) let focusEl = document.querySelector(thisFocus) || document.body.querySelector('[autofocus]') // see if there's a declared focus element if (focusEl && !focusEl.closest('[inert], [aria-disabled], [aria-hidden="true"]')) focusEl = null // don't focus elements that have been declared inert if (focusEl && focusEl !== document.activeElement) { focusEl.focus() // only focus if not already focused if (!validElementsForOutline.includes(focusEl.tagName)) focusEl.style.outline = 'none' } else { // focus the target element instead (defined as the first element that appears in the targets array) // apply a tabindex attribute to allow focusing non-focusable elements const targetEl = document.querySelector(targets[0]) const originalTabindex = targetEl.getAttribute('tabindex') targetEl.setAttribute('tabindex', '-1') targetEl.focus({ preventScroll: true }) if (!validElementsForOutline.includes(targetEl.tagName)) targetEl.style.outline = 'none' if (originalTabindex !== null) targetEl.setAttribute('tabindex', originalTabindex) } // call afterRender methods if they exist if (app.afterEveryRender && typeof app.afterEveryRender === 'function') app.afterEveryRender(beforeAfterRenderArg) // call app.afterEveryRender function if it exists if (thisAfterRender && typeof thisAfterRender === 'function') thisAfterRender(beforeAfterRenderArg) // call res.afterRender function if it exists } else { const msg = `single-page-express: invalid target supplied: ${target}` console.error(msg) err = msg } } // call user-defined callback supplied to the render method if it exists if (callback && typeof callback === 'function') callback(err, markup) postRenderCallbacks() } if (document.startViewTransition) currentViewTransition = document.startViewTransition(domUpdate) else domUpdate() }, parseInt(thisUpdateDelay) || parseInt(app.updateDelay) || 0) }) } } } } } res.render = app.render // they are slightly different methods in express but there is no reason to differentiate between them here // #endregion // #region start the router if (!document.singlePageExpressEventListenerAdded) { // listen for link navigation events document.addEventListener('click', (event) => { if (event.target.tagName === 'A' && event.target.href) handleRoute({ route: event.target.getAttribute('href'), event }) }) // listen for form submits document.addEventListener('submit', (event) => { if (event.target.getAttribute('action')) handleRoute({ route: event.target.getAttribute('action'), event, parseBody: true, method: event.target.getAttribute('method') }) }) } document.singlePageExpressEventListenerAdded = true // prevent attaching the event to the DOM twice // listen for back/forward button properly const historyStack = [] let currentIndex = -1 if (!window.singlePageExpressGlobalsInitialized) { window.singlePageExpressGlobalsInitialized = true // this check prevents the event listener from being loaded multiple times if this constructor gets executed more than once window.addEventListener('popstate', (event) => { const state = event.state if (!state) return let backButtonPressed = false let forwardButtonPressed = false const newIndex = state.index if (newIndex < currentIndex) backButtonPressed = true else if (newIndex > currentIndex) forwardButtonPressed = true currentIndex = newIndex handleRoute({ route: window.location.pathname, method: 'get', skipHistory: true, // skipHistory prevents adding a new entry to history when responding to a back/forward button request backButtonPressed, forwardButtonPressed }) }) } // #endregion return app } module.exports = singlePageExpress