single-page-express
Version:
📄 A client-side implementation of the Express route API.
424 lines • 48 kB
JavaScript
(function(root,factory){if(typeof exports==="object"&&typeof module==="object")module.exports=factory();else if(typeof define==="function"&&define.amd)define("singlePageExpress",[],factory);else if(typeof exports==="object")exports["singlePageExpress"]=factory();else root["singlePageExpress"]=factory()})(this,(()=>/******/(()=>{// webpackBootstrap
/******/var __webpack_modules__={
/***/67:
/***/(__unused_webpack_module,exports)=>{"use strict";0;0;0;0;exports.YW=match;0;0;const DEFAULT_DELIMITER="/";const NOOP_VALUE=value=>value;const ID_START=/^[$_\p{ID_Start}]$/u;const ID_CONTINUE=/^[$\u200c\u200d\p{ID_Continue}]$/u;const DEBUG_URL="https://git.new/pathToRegexpError";const SIMPLE_TOKENS={
// Groups.
"{":"{","}":"}",
// Reserved.
"(":"(",")":")","[":"[","]":"]","+":"+","?":"?","!":"!"};
/**
* Escape text for stringify to path.
*/function escapeText(str){return str.replace(/[{}()\[\]+?!:*]/g,"\\$&")}
/**
* Escape a regular expression string.
*/function escape(str){return str.replace(/[.+*?^${}()[\]|/\\]/g,"\\$&")}
/**
* Tokenize input string.
*/function*lexer(str){const chars=[...str];let i=0;function name(){let value="";if(ID_START.test(chars[++i])){value+=chars[i];while(ID_CONTINUE.test(chars[++i]))value+=chars[i]}else if(chars[i]==='"'){let pos=i;while(i<chars.length){if(chars[++i]==='"'){i++;pos=0;break}if(chars[i]==="\\")value+=chars[++i];else value+=chars[i]}if(pos)throw new TypeError(`Unterminated quote at ${pos}: ${DEBUG_URL}`)}if(!value)throw new TypeError(`Missing parameter name at ${i}: ${DEBUG_URL}`);return value}while(i<chars.length){const value=chars[i];const type=SIMPLE_TOKENS[value];if(type)yield{type,index:i++,value};else if(value==="\\")yield{type:"ESCAPED",index:i++,value:chars[i++]};else if(value===":"){const value=name();yield{type:"PARAM",index:i,value}}else if(value==="*"){const value=name();yield{type:"WILDCARD",index:i,value}}else yield{type:"CHAR",index:i,value:chars[i++]}}return{type:"END",index:i,value:""}}class Iter{constructor(tokens){this.tokens=tokens}peek(){if(!this._peek){const next=this.tokens.next();this._peek=next.value}return this._peek}tryConsume(type){const token=this.peek();if(token.type!==type)return;this._peek=void 0;// Reset after consumed.
return token.value}consume(type){const value=this.tryConsume(type);if(value!==void 0)return value;const{type:nextType,index}=this.peek();throw new TypeError(`Unexpected ${nextType} at ${index}, expected ${type}: ${DEBUG_URL}`)}text(){let result="";let value;while(value=this.tryConsume("CHAR")||this.tryConsume("ESCAPED"))result+=value;return result}}
/**
* Tokenized path instance.
*/class TokenData{constructor(tokens){this.tokens=tokens}}0;
/**
* Parse a string for the raw tokens.
*/function parse(str,options={}){const{encodePath=NOOP_VALUE}=options;const it=new Iter(lexer(str));function consume(endType){const tokens=[];while(true){const path=it.text();if(path)tokens.push({type:"text",value:encodePath(path)});const param=it.tryConsume("PARAM");if(param){tokens.push({type:"param",name:param});continue}const wildcard=it.tryConsume("WILDCARD");if(wildcard){tokens.push({type:"wildcard",name:wildcard});continue}const open=it.tryConsume("{");if(open){tokens.push({type:"group",tokens:consume("}")});continue}it.consume(endType);return tokens}}const tokens=consume("END");return new TokenData(tokens)}
/**
* Compile a string to a template function for the path.
*/function compile(path,options={}){const{encode=encodeURIComponent,delimiter=DEFAULT_DELIMITER}=options;const data=path instanceof TokenData?path:parse(path,options);const fn=tokensToFunction(data.tokens,delimiter,encode);return function(data={}){const[path,...missing]=fn(data);if(missing.length)throw new TypeError(`Missing parameters: ${missing.join(", ")}`);return path}}function tokensToFunction(tokens,delimiter,encode){const encoders=tokens.map((token=>tokenToFunction(token,delimiter,encode)));return data=>{const result=[""];for(const encoder of encoders){const[value,...extras]=encoder(data);result[0]+=value;result.push(...extras)}return result}}
/**
* Convert a single token into a path building function.
*/function tokenToFunction(token,delimiter,encode){if(token.type==="text")return()=>[token.value];if(token.type==="group"){const fn=tokensToFunction(token.tokens,delimiter,encode);return data=>{const[value,...missing]=fn(data);if(!missing.length)return[value];return[""]}}const encodeValue=encode||NOOP_VALUE;if(token.type==="wildcard"&&encode!==false)return data=>{const value=data[token.name];if(value==null)return["",token.name];if(!Array.isArray(value)||value.length===0)throw new TypeError(`Expected "${token.name}" to be a non-empty array`);return[value.map(((value,index)=>{if(typeof value!=="string")throw new TypeError(`Expected "${token.name}/${index}" to be a string`);return encodeValue(value)})).join(delimiter)]};return data=>{const value=data[token.name];if(value==null)return["",token.name];if(typeof value!=="string")throw new TypeError(`Expected "${token.name}" to be a string`);return[encodeValue(value)]}}
/**
* Transform a path into a match function.
*/function match(path,options={}){const{decode=decodeURIComponent,delimiter=DEFAULT_DELIMITER}=options;const{regexp,keys}=pathToRegexp(path,options);const decoders=keys.map((key=>{if(decode===false)return NOOP_VALUE;if(key.type==="param")return decode;return value=>value.split(delimiter).map(decode)}));return function(input){const m=regexp.exec(input);if(!m)return false;const path=m[0];const params=Object.create(null);for(let i=1;i<m.length;i++){if(m[i]===void 0)continue;const key=keys[i-1];const decoder=decoders[i-1];params[key.name]=decoder(m[i])}return{path,params}}}function pathToRegexp(path,options={}){const{delimiter=DEFAULT_DELIMITER,end=true,sensitive=false,trailing=true}=options;const keys=[];const sources=[];const flags=sensitive?"":"i";const paths=Array.isArray(path)?path:[path];const items=paths.map((path=>path instanceof TokenData?path:parse(path,options)));for(const{tokens}of items)for(const seq of flatten(tokens,0,[])){const regexp=sequenceToRegExp(seq,delimiter,keys);sources.push(regexp)}let pattern=`^(?:${sources.join("|")})`;if(trailing)pattern+=`(?:${escape(delimiter)}$)?`;pattern+=end?"$":`(?=${escape(delimiter)}|$)`;const regexp=new RegExp(pattern,flags);return{regexp,keys}}
/**
* Generate a flat list of sequence tokens from the given tokens.
*/function*flatten(tokens,index,init){if(index===tokens.length)return yield init;const token=tokens[index];if(token.type==="group"){const fork=init.slice();for(const seq of flatten(token.tokens,0,fork))yield*flatten(tokens,index+1,seq)}else init.push(token);yield*flatten(tokens,index+1,init)}
/**
* Transform a flat sequence of tokens into a regular expression.
*/function sequenceToRegExp(tokens,delimiter,keys){let result="";let backtrack="";let isSafeSegmentParam=true;for(let i=0;i<tokens.length;i++){const token=tokens[i];if(token.type==="text"){result+=escape(token.value);backtrack+=token.value;isSafeSegmentParam||(isSafeSegmentParam=token.value.includes(delimiter));continue}if(token.type==="param"||token.type==="wildcard"){if(!isSafeSegmentParam&&!backtrack)throw new TypeError(`Missing text after "${token.name}": ${DEBUG_URL}`);if(token.type==="param")result+=`(${negate(delimiter,isSafeSegmentParam?"":backtrack)}+)`;else result+=`([\\s\\S]+)`;keys.push(token);backtrack="";isSafeSegmentParam=false;continue}}return result}function negate(delimiter,backtrack){if(backtrack.length<2){if(delimiter.length<2)return`[^${escape(delimiter+backtrack)}]`;return`(?:(?!${escape(delimiter)})[^${escape(backtrack)}])`}if(delimiter.length<2)return`(?:(?!${escape(backtrack)})[^${escape(delimiter)}])`;return`(?:(?!${escape(backtrack)}|${escape(delimiter)})[\\s\\S])`}
/**
* Stringify token data into a path string.
*/function stringify(data){return data.tokens.map((function stringifyToken(token,index,tokens){if(token.type==="text")return escapeText(token.value);if(token.type==="group")return`{${token.tokens.map(stringifyToken).join("")}}`;const isSafe=isNameSafe(token.name)&&isNextNameSafe(tokens[index+1]);const key=isSafe?token.name:JSON.stringify(token.name);if(token.type==="param")return`:${key}`;if(token.type==="wildcard")return`*${key}`;throw new TypeError(`Unexpected token: ${token}`)})).join("")}function isNameSafe(name){const[first,...rest]=name;if(!ID_START.test(first))return false;return rest.every((char=>ID_CONTINUE.test(char)))}function isNextNameSafe(token){if((token===null||token===void 0?void 0:token.type)!=="text")return true;return!ID_CONTINUE.test(token.value[0])}
//# sourceMappingURL=index.js.map
/***/},
/***/297:
/***/module=>{
/**
* Expose `pathToRegexp`.
*/
module.exports=pathToRegexp;
/**
* Match matching groups in a regular expression.
*/var MATCHING_GROUP_REGEXP=/\\.|\((?:\?<(.*?)>)?(?!\?)/g;
/**
* Normalize the given path string,
* returning a regular expression.
*
* An empty array should be passed,
* which will contain the placeholder
* key names. For example "/user/:id" will
* then contain ["id"].
*
* @param {String|RegExp|Array} path
* @param {Array} keys
* @param {Object} options
* @return {RegExp}
* @api private
*/function pathToRegexp(path,keys,options){options=options||{};keys=keys||[];var strict=options.strict;var end=options.end!==false;var flags=options.sensitive?"":"i";var lookahead=options.lookahead!==false;var extraOffset=0;var keysOffset=keys.length;var i=0;var name=0;var pos=0;var backtrack="";var m;if(path instanceof RegExp){while(m=MATCHING_GROUP_REGEXP.exec(path.source)){if(m[0][0]==="\\")continue;keys.push({name:m[1]||name++,optional:false,offset:m.index})}return path}if(Array.isArray(path)){
// Map array parts into regexps and return their source. We also pass
// the same keys and options instance into every generation to get
// consistent matching groups before we join the sources together.
path=path.map((function(value){return pathToRegexp(value,keys,options).source}));return new RegExp(path.join("|"),flags)}if(typeof path!=="string")throw new TypeError("path must be a string, array of strings, or regular expression");path=path.replace(/\\.|(\/)?(\.)?:(\w+)(\(.*?\))?(\*)?(\?)?|[.*]|\/\(/g,(function(match,slash,format,key,capture,star,optional,offset){if(match[0]==="\\"){backtrack+=match;pos+=2;return match}if(match==="."){backtrack+="\\.";extraOffset+=1;pos+=1;return"\\."}if(slash||format)backtrack="";else backtrack+=path.slice(pos,offset);pos=offset+match.length;if(match==="*"){extraOffset+=3;return"(.*)"}if(match==="/("){backtrack+="/";extraOffset+=2;return"/(?:"}slash=slash||"";format=format?"\\.":"";optional=optional||"";capture=capture?capture.replace(/\\.|\*/,(function(m){return m==="*"?"(.*)":m})):backtrack?"((?:(?!/|"+backtrack+").)+?)":"([^/"+format+"]+?)";keys.push({name:key,optional:!!optional,offset:offset+extraOffset});var result="(?:"+format+slash+capture+(star?"((?:[/"+format+"].+?)?)":"")+")"+optional;extraOffset+=result.length-match.length;return result}));
// This is a workaround for handling unnamed matching groups.
while(m=MATCHING_GROUP_REGEXP.exec(path)){if(m[0][0]==="\\")continue;if(keysOffset+i===keys.length||keys[keysOffset+i].offset>m.index)keys.splice(keysOffset+i,0,{name:name++,// Unnamed matching groups must be consistently linear.
optional:false,offset:m.index});i++}path+=strict?"":path[path.length-1]==="/"?"?":"/?";
// If the path is non-ending, match until the end or a slash.
if(end)path+="$";else if(path[path.length-1]!=="/")path+=lookahead?"(?=/|$)":"(?:/|$)";return new RegExp("^"+path,flags)}},
/***/524:
/***/(module,__unused_webpack_exports,__webpack_require__)=>{const pathToRegexpMatch=__webpack_require__(67)/* .match */.YW;// route parser for express 5+
const pathToRegexpMatchExpress4=__webpack_require__(297);// 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=__webpack_require__(663);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=>!app.appVars[name];app.enable=name=>{app.appVars[name]=true};app.enabled=name=>!!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=()=>res;// stubbed out
res.attachment=()=>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=()=>res;// stubbed out
res.end=()=>res;// stubbed out
res.format=()=>res;// stubbed out
res.get=()=>res;// stubbed out
res.json=json=>{console.log(json);return res};res.jsonp=()=>res;// stubbed out
res.links=()=>res;// stubbed out
res.location=()=>res;// stubbed out
res.redirect=(status,route)=>{if(!route)route=status;handleRoute({route});return res};
// res.render is defined below
res.send=()=>res;// stubbed out
res.sendFile=()=>res;// stubbed out
res.sendStatus=()=>res;// stubbed out
res.set=()=>res;// stubbed out
res.status=()=>res;// stubbed out
res.type=()=>res;// stubbed out
res.vary=()=>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=void 0;// 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
/***/},
/***/663:
/***/function(module,exports,__webpack_require__){var __WEBPACK_AMD_DEFINE_RESULT__;
/**
* @license MIT
* topbar 3.0.0
* http://buunguyen.github.io/topbar
* Copyright (c) 2024 Buu Nguyen
*/(function(window,document){"use strict";function repaint(){canvas.width=window.innerWidth,canvas.height=5*options.barThickness;var ctx=canvas.getContext("2d");ctx.shadowBlur=options.shadowBlur,ctx.shadowColor=options.shadowColor;var stop,lineGradient=ctx.createLinearGradient(0,0,canvas.width,0);for(stop in options.barColors)lineGradient.addColorStop(stop,options.barColors[stop]);ctx.lineWidth=options.barThickness,ctx.beginPath(),ctx.moveTo(0,options.barThickness/2),ctx.lineTo(Math.ceil(currentProgress*canvas.width),options.barThickness/2),ctx.strokeStyle=lineGradient,ctx.stroke()}var canvas,currentProgress,showing,progressTimerId=null,fadeTimerId=null,delayTimerId=null,options={autoRun:!0,barThickness:3,barColors:{0:"rgba(26, 188, 156, .9)",".25":"rgba(52, 152, 219, .9)",".50":"rgba(241, 196, 15, .9)",".75":"rgba(230, 126, 34, .9)","1.0":"rgba(211, 84, 0, .9)"},shadowBlur:10,shadowColor:"rgba(0, 0, 0, .6)",className:null},topbar={config:function(opts){for(var key in opts)options.hasOwnProperty(key)&&(options[key]=opts[key])},show:function(handler){var type,elem;showing||(handler?delayTimerId=delayTimerId||setTimeout((()=>topbar.show()),handler):(showing=!0,null!==fadeTimerId&&window.cancelAnimationFrame(fadeTimerId),canvas||((elem=(canvas=document.createElement("canvas")).style).position="fixed",elem.top=elem.left=elem.right=elem.margin=elem.padding=0,elem.zIndex=100001,elem.display="none",options.className&&canvas.classList.add(options.className),type="resize",handler=repaint,(elem=window).addEventListener?elem.addEventListener(type,handler,!1):elem.attachEvent?elem.attachEvent("on"+type,handler):elem["on"+type]=handler),canvas.parentElement||document.body.appendChild(canvas),canvas.style.opacity=1,canvas.style.display="block",topbar.progress(0),options.autoRun&&function loop(){progressTimerId=window.requestAnimationFrame(loop),topbar.progress("+"+.05*Math.pow(1-Math.sqrt(currentProgress),2))}()))},progress:function(to){return void 0===to||("string"==typeof to&&(to=(0<=to.indexOf("+")||0<=to.indexOf("-")?currentProgress:0)+parseFloat(to)),currentProgress=1<to?1:to,repaint()),currentProgress},hide:function(){clearTimeout(delayTimerId),delayTimerId=null,showing&&(showing=!1,null!=progressTimerId&&(window.cancelAnimationFrame(progressTimerId),progressTimerId=null),function loop(){return 1<=topbar.progress("+.1")&&(canvas.style.opacity-=.05,canvas.style.opacity<=.05)?(canvas.style.display="none",void(fadeTimerId=null)):void(fadeTimerId=window.requestAnimationFrame(loop))}())}};true&&"object"==typeof module.exports?module.exports=topbar:true?!(__WEBPACK_AMD_DEFINE_RESULT__=function(){return topbar}.call(exports,__webpack_require__,exports,module),__WEBPACK_AMD_DEFINE_RESULT__!==void 0&&(module.exports=__WEBPACK_AMD_DEFINE_RESULT__)):0}).call(this,window,document);
/***/}
/******/};
/************************************************************************/
/******/ // The module cache
/******/var __webpack_module_cache__={};
/******/
/******/ // The require function
/******/function __webpack_require__(moduleId){
/******/ // Check if module is in cache
/******/var cachedModule=__webpack_module_cache__[moduleId];
/******/if(cachedModule!==void 0)
/******/return cachedModule.exports;
/******/
/******/ // Create a new module (and put it into the cache)
/******/var module=__webpack_module_cache__[moduleId]={
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/exports:{}
/******/};
/******/
/******/ // Execute the module function
/******/__webpack_modules__[moduleId].call(module.exports,module,module.exports,__webpack_require__);
/******/
/******/ // Return the exports of the module
/******/return module.exports;
/******/}
/******/
/************************************************************************/
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module is referenced by other modules so it can't be inlined
/******/var __webpack_exports__=__webpack_require__(524);
/******/
/******/return __webpack_exports__;
/******/})()));
//# sourceMappingURL=single-page-express.js.map