UNPKG

@luketclancy/otterly

Version:

A javascript front end framework, Inspired by stimulus js. Like an otter its small, fast and versatile. Based around logical units attached to html nodes. Great for a backend-first approach to website creation.

526 lines (476 loc) 15.6 kB
import morphdom from 'morphdom' export default { init(isDev, afterDive, csrfSelector, csrfHeader){ return {isDev, afterDive, csrfSelector, csrfHeader, ...this} }, obj_to_fd(formInfo, formData) { if(formInfo instanceof FormData) { return formInfo } else { //get good 🦄 let recursed = (formData, key, item) => { let key2, item2 if(Array.isArray(item)) { for(key2 in item) { item2 = item[key2] recursed(formData, key + "[]", item2) } } else if(typeof item === 'object') { for(key2 in item) { item2 = item[key2] recursed(formData, key + "[" + key2 + "]", item2) } } else { formData.append(key, item) } } if(!formData){ formData = new FormData(); } let key; for(key in formInfo){ let item = formInfo[key] recursed(formData, key, item) } return formData } }, _sendsXHROnLoad(resolve, reject, xhr, responseType){ if(xhr.status >= 200 && xhr.status <= 302 && xhr.status != 300) { let rsp = xhr.response if(responseType == 'json'){ try { rsp = JSON.parse(rsp) } catch {} } resolve({response: rsp, xhr: xhr}) // get xhr.json for the json. } else { reject({status: xhr.status, statusText: xhr.statusText}); } }, _sendsXHROnError(resolve, reject, xhr){ reject({ status: xhr.status, statusText: xhr.statusText }); }, sendsXHR({url, formInfo, method = "POST", xhrChangeF, csrfContent, csrfHeader = this.csrfHeader, csrfSelector = this.csrfSelector, confirm, withCredentials = true, responseType="json", onload = this._sendsXHROnLoad, onerror = this._sendsXHROnError}){ if(!csrfContent){ csrfContent = document.querySelector(csrfSelector).content } return new Promise(function(resolve, reject) { var xhr, form_data; xhr = new XMLHttpRequest(); xhr.withCredentials = withCredentials xhr.open(method, url) xhr.responseType=responseType xhr.onload = onload.bind(this, resolve, reject, xhr, responseType) xhr.onerror = onerror.bind(this, resolve, reject, xhr) //get formInfo into the form_data form_data = this.obj_to_fd(formInfo) xhr.setRequestHeader(csrfHeader, csrfContent) //helper so we know where this came from. Super useful when for example, checking //if someones signed in, and figuring out how to notify them that they are not //redirect back with a flash? Or just morph a message up? xhr.setRequestHeader('Otty', 'true') //add a file or something if you want go nuts if(xhrChangeF) { xhr = xhrChangeF(xhr) } if(confirm) { confirm = confirm(confirm) if(confirm) { xhr.send(form_data) } else { resolve({'returning': 'user rejected confirm prompt'}) } } else { xhr.send(form_data) } }.bind(this)) }, xss_pass(url){ return this.isLocalUrl(url, -2) }, dive(opts = {}){ //divewire can be a security risk as its so dynamic, so make sure we are only connecting with ourselves... let url = opts.url let baseElement = opts.baseElement let submitter = opts.submitter if(opts.e != null) { if(baseElement == null) { baseElement = opts.e.currentTarget } if(submitter == null) { submitter = opts.e.submitter } } if(!this.xss_pass(url)){ throw url + " is not a local_url" } let handle_response = ((actions, resolve, reject) => { let y, ottys_capabilities, task, data, out, returning, dive_id, action returning = actions if(!Array.isArray(actions)) { actions = [actions] } y = 0 ottys_capabilities = this.afterDive.init(baseElement, submitter, resolve, reject, this.isDev) for(action of actions) { if(!action){continue} //make sure we have not already processed this dive (matters with polling) dive_id = action.dive_id if(dive_id) { if( this.previousDives.includes(dive_id) ) { continue; } this.previousDives.push(dive_id) delete action.dive_id } //get ottys task task = Object.keys(action)[0] data = action[task] if(task == 'eval'){task = 'eval2'} if(this.isDev){ console.log(task, data) } if(task == 'returning') { returning = data } else { try { out = ottys_capabilities[task](data) } catch(err) { if(this.isDev){ console.log(task, data, err, err.message) } } if(out == "break"){ break } } } resolve(returning) }).bind(this) return new Promise(function(resolve, reject) { this.sendsXHR(opts).then((obj) => { handle_response(obj.response, resolve, reject) }).catch((e) => { reject(e) }) }.bind(this)) }, //this will default to replacing body if this css selector naught found. async stopGoto(href){ if(!this.handlingNavigation){ //handle use case where person does not want spa, which, after headaches, fair enough. //as linkclickedf was never activated, this should only happen through afterDive window.location.href = href return true } //Check scroll to hash on same page let loc = window.location href = new URL(href, loc) //hashes if(loc.origin == href.origin && href.pathname == loc.pathname){ return await this.scrollToLocationHashElement(href) } //I wanted my subdomains to be counted too... apparently not possible... if(loc.origin != href.origin){ window.location.href = href return true } return false }, isLocalUrl(url, subdomainAccuracy = -2){ //local includes subdomains. So if we are on x.com, x.com will work and y.x.com will work, but y.com wont. //change the -2 to -3, -4 etc to modify. Times where this may be an issue: // - if you share domains with untrusted partys. let d = window.location.hostname let urld = (new URL(url, window.location)).hostname //url_with_default_host let opt1 = d.split('.').slice(subdomainAccuracy).join('.') let opt2 = urld.split('.').slice(subdomainAccuracy).join('.') return (opt1 == opt2) }, async linkClickedF(e) { let href = e.target.closest('[href]') if(!href){ return } if(href.dataset.nativeHref != undefined){return} href = href.getAttribute('href') if(!this.isLocalUrl(href, -99)){ return } //prevent default if we do not handle //cancel their thing e.preventDefault() e.stopPropagation() await this.goto(href) return }, async scrollToLocationHashElement(loc){ if(!loc.hash){ return false } let e = document.getElementById(decodeURIComponent(loc.hash.slice(1))) if(!e){ return false } await this.waitForImages() e.scrollIntoView() return true }, async goto(href, opts = {}){ if(await this.stopGoto(href)){ return -1 } opts = {reload: false, ...opts} let loc = window.location href = new URL(href, loc) //start getting the new info let prom = this.sendsXHR({ url: href, method: "GET", responseType: "text", //<- dont try to json parse results xhrChangeF: (xhr) => { xhr.setRequestHeader('Otty-Nav', 'true'); return xhr } //<- header so server knows regular GET vs other otty requests }) //get and replace page prom = await prom let page = prom.response, xhr = prom.xhr //in case of redirect... if(xhr.responseURL){ let nhref = new URL(xhr.responseURL) nhref.hash = href.hash href = nhref } //replace page , starting at the top of the page. Update page state for where we were before the switch. //Note it is important to store the replacement html after removal to allow for things such as onRemoved // to run before we store. await this.pageReplace(page, 0, href, (BefBodyClone, befY) => { this.replacePageState(loc, BefBodyClone, befY) if(!(opts.reload)){ //store the new page information. this.pushPageState(href, undefined) } }, loc) return href }, createStorageDoc(orienter, head){ if(!Array.isArray(orienter)){ orienter = [orienter]} orienter = orienter.map( (x) => x.cloneNode(true) ) let storeDoc = (new DOMParser()).parseFromString('<!DOCTYPE HTML> <html></html>', 'text/html') if(orienter.length == 1 && orienter[0].nodeName == "BODY"){ storeDoc.body = orienter[0] } else { for(let o of orienter){storeDoc.body.appendChild(o)} } morphdom(storeDoc.head, head.cloneNode(true)) return storeDoc }, navigationHeadMorph(tempdocHead){ // this is what my custom otty looks like after I hit an edge case where an external //library was adding to my head but then it would get reset on nav: // // morphdom(document.head, tempdocHead, this.afterDive._morphOpts({permanent: '[href*="google"], [src*="google"], [id*="google"]'})) morphdom(document.head, tempdocHead) }, navigationBodyChange(orienter, tmpOrienter) { let x = 0 while(x < orienter.length){ if(orienter[x].nodeName == "BODY"){ orienter[x].innerHTML = tmpOrienter[x].innerHTML //javascript wise, its useful for the body's attributes to remain the same. //css wise, its a headache. So pass the class and style, but not the rest. orienter[x].setAttribute('class', (tmpOrienter[x].getAttribute('class') || '')) orienter[x].setAttribute('style', (tmpOrienter[x].getAttribute('style') || '')) } else { orienter[x].replaceWith(tmpOrienter[x]) } x += 1 } }, getOrienters(tempdoc, url, lastUrl, ){ //this method may be overrode for more functionality. //Orienters can be an array, and they will still store properly. //This can allow you to fine tune page updates. For example, changing the notifications //and a post's contents without changing the layout. Or switching in an email without changing the rest of the page. //This also necessitates changing navigationBodyChange to deal with it. let newOrienters, orienters, replaceSelector, fail, a, b for(replaceSelector of this.navigationReplaces){ if(!Array.isArray(replaceSelector)){ replaceSelector = [replaceSelector]} fail = false; orienters = []; newOrienters = [] for(let s of replaceSelector){ a = document.querySelector(s) if(!a){fail = true; break} b = tempdoc.querySelector(s) if(!b){fail=true; break} orienters.push(a); newOrienters.push(b) } if(!fail){ return [orienters, newOrienters] } } }, async pageReplace(tempdoc, scroll, url, beforeReplace, lastUrl){ let befY = window.scrollY //standardize tempdoc (accept strings) if(typeof tempdoc == "string") { tempdoc = (new DOMParser()).parseFromString(tempdoc, "text/html") } let orienters, newOrienters [orienters, newOrienters] = this.getOrienters(tempdoc, url, lastUrl) //been having issues with the removed thing triggering as the observer is on the body which we are removing. // if(orienters[0].nodeName == "BODY"){ // for(let unitEl of this.qsInclusive(orienters[0], '[data-unit]')){ // this.stopError( () => unitEl._unit?.unitRemoved() ) // } // } //set stored information for recreating current page let storeDoc = this.createStorageDoc(orienters, document.head) //placement of this is important since we need to change the url and state after killing all the previous units //but before creating all the new units and event handles. For instance, this breaks _parse->dive[{"behavior": "repeat"}] since //the thing quick cancels since it thinks it left the page lol. if(beforeReplace){beforeReplace(storeDoc, befY)} // orienter.replaceChildren(...tmpOrienter.children) this.navigationBodyChange(orienters, newOrienters) //morph the head to the new head. Throw into a different function for //any strangeness that one may encounter and this.navigationHeadMorph(tempdoc.querySelector('head')) let shouldScrollToEl = (url && (!scroll)) //handle scrolling let scrolled = false if(shouldScrollToEl){ scrolled = await this.scrollToLocationHashElement(url) } if(!scrolled){ if(scroll != 0){await this.waitForImages()} window.scroll(0, scroll) } }, async waitForImages(){ let arr = Array.from(document.body.querySelectorAll('img')).map((im)=>{ new Promise((resolve) => { im.addEventListener('load', resolve) if(im.complete){resolve()} }) }) for(let a of arr){await a} return true }, stopError(f){ try{ f() } catch(e) { otty.log(e) } }, _pageState(scroll, doc, url){ this.historyReferences[this.historyReferenceId] = { doc: doc, scroll: scroll, url: url, tn: (new Date()).getTime() } }, replacePageState(url, doc, scroll){ window.history.replaceState({ historyReferenceId: (this.historyReferenceId), }, "", url); this._pageState(scroll, doc, url) }, pushPageState(url, doc){ window.history.pushState({ historyReferenceId: (this.historyReferenceId = Math.random()), }, "", url) this._pageState(0, doc, url) }, qsInclusive(n, pat){ let units = Array.from(n.querySelectorAll(pat)) if(n.matches(pat)){units.push(n)} return units }, handleNavigation(opts = {}){ opts = {navigationReplaces: ['body'], ...opts} this.navigationReplaces = opts.navigationReplaces this.historyReferenceId = Math.random() this.historyReferences = {} this.handlingNavigation = true history.scrollRestoration = 'manual' document.addEventListener('click', this.linkClickedF.bind(this)) window.addEventListener('popstate', (async function (e){ if(e.state && ( e.state.historyReferenceId != undefined)){ let lastInf = this.historyReferences[this.historyReferenceId] let hr = this.historyReferences[( this.historyReferenceId = e.state.historyReferenceId )] if(hr){ await this.pageReplace(hr.doc, hr.scroll, hr.url, (strDoc, befY) => { lastInf.scroll = befY lastInf.doc = strDoc }, lastInf.url) } else { //if they refresh and hit the back button or something it can make things difficult //especially since we still get the state information (thats where the e.state.match comes forward.) this.historyReferenceId = Math.random() this.goto(window.location, {reload: true}) } } }).bind(this)) //do not rely on eachother // this.updatePageState(window.location, {push: false}) this.scrollToLocationHashElement(window.location) }, previousDives: [], poll(dat){ if(this.ActivePollId != dat.id) { return } let maybeResub = ((x)=>{ if(x == 'should_resub') { this.subscribeToPoll(dat.queues, dat.pollInfo, dat.waitTime, dat.pollPath, dat.subPath) } else if(x != "no_updates") { dat.store = x } }).bind(this) let continuePolling = (()=>{ let poll = (()=>{ this.poll(dat) }).bind(this) setTimeout(poll, dat.waitTime) }).bind(this) let fi = {} if(dat.store){fi = {'otty-store': dat.store}} this.dive({ url: dat.pollPath, formInfo: fi }).then(maybeResub).finally(continuePolling) }, subscribeToPoll(queues, pollInfo, waitTime, pollPath, subPath){ this.pollPath = pollPath let id = Math.random() this.ActivePollId = id let dat = { queues, pollInfo, waitTime, id, pollPath, subPath } let poll = ((out) => { if(out == 'no_queues') { if(this.isDev){console.log('no_queues', out)} } else { dat.store = out this.poll(dat) } }).bind(this) let err_log = ((x)=>{ if(this.isDev){console.error('sub fail', x)} }).bind(this) this.dive({ url: subPath, formInfo: { queues: dat.queues, ...dat.pollInfo } }).then(poll, err_log) }, logData: [], log: (data, isError) => { let t = 'noTrace' try { t = new Error().stack } catch{ } if(isError){ console.error(data) } else { console.log(data) } otty.logData.push(JSON.stringify({ time: (new Date).getTime(), loc: window.location.href, stack: t, data: data, })) } }