UNPKG

bbpro

Version:

BrowserBox - remote browser isolation tool

1,199 lines (1,075 loc) 42.2 kB
// eslint directives /* eslint-disable no-empty */ // vanillaview.js // imports const CODE = Math.random().toFixed(18); const IMMEDIATE = Symbol.for(`[[IMMEDIATE]]`); // backwards compatible alias const skip = markup; const attrskip = attrmarkup; // constants const DEBUG = false; const NULLFUNC = () => void 0; /* eslint-disable no-useless-escape */ const KEYMATCH = /(?:<!\-\-)?(key0.\d+)(?:\-\->)?/gm; /* eslint-enable no-useless-escape */ const ATTRMATCH = /\w+=/; const JOINER = '<link rel=join>'; const KEYLEN = 20; const XSS = () => `Possible XSS / object forgery attack detected. ` + `Object code could not be verified.`; const OBJ = x => ({message:`Object values not allowed here.`, x}); const UNSET = () => `Unset values not allowed here.`; const INSERT = () => `Error inserting template into DOM. ` + `Position must be one of: ` + `replace, beforebegin, afterbegin, beforeend, innerhtml, afterend`; const NOTFOUND = loc => `Error inserting template into DOM. ` + `Location ${loc} was not found in the document.`; const MOVE = new class { beforeend (frag,elem) { elem.appendChild(frag) } beforebegin (frag,elem) { elem.parentNode.insertBefore(frag,elem) } afterend (frag,elem) { elem.parentNode.insertBefore(frag,elem.nextSibling) } replace (frag,elem) { elem.parentNode.replaceChild(frag,elem) } afterbegin (frag,elem) { elem.insertBefore(frag,elem.firstChild) } innerhtml (frag,elem) { elem.replaceChildren(); elem.appendChild(frag) } insert (frag,node) { node.replaceChildren(frag) } }; const REMOVE_MAP = new Map(); const DIV = document.createElement('div'); const POS = 'beforeend'; const EMPTY = ''; const {stringify:_STR} = JSON; const JS = o => _STR(o, Replacer, EMPTY); const isVV = x => (x?.code === CODE || (x?.type && ( x.type === 'MarkupObject' || x.type === 'MarkupAttrObject' ))) && Array.isArray(x?.nodes); const NextFunc = () => `f${FuncCounter++}` + (Math.random()*10).toString(36).replace('.', '_'); // logging //globalThis.onerror = (...v) => (console.log(v, v[0]+EMPTY, v[4] && v[4].message, v[4] && v[4].stack), true); // type functions const isKey = v => !!v && (typeof v.key === 'string' || typeof v.key === 'number') && Object.getOwnPropertyNames(v).length <= 2; // state let FuncCounter = 10; // cache const cache = {}; let _CONFIG; // deux // main exports Object.assign(s,{attrskip,skip,attrmarkup,markup,guardEmptyHandlers,die}); Object.assign(globalThis, {vanillaview: {c, s}}); export async function s(p,...v) { const that = this; let SystemCall = false; let state, _host; if ( p[0].length === 0 && v[0].state ) { // by convention (see how we construct the template that we tag with FUNC) // the first value is the state object when our system calls it SystemCall = true; } const {key} = v.find(isKey) || {}; if ( SystemCall ) { ({state,_host} = v.shift()); p.shift(); v = await Promise.all(v.map(val => process(that, val, state, _host))); const xyz = vanillaview(p,v, {_host}); //xyz[Symbol.for('BANG-VV')] = true; DEBUG && console.log({state}, self.__state = state); return xyz; } else { const laterFunc = async (state, _host) => { DEBUG && console.log({state}, self.__state = state); v = await Promise.all(v.map(val => process(that, val, state, _host))); const xyz = vanillaview(p,v, {_host}); //xyz[Symbol.for('BANG-VV')] = true; return xyz; }; laterFunc[IMMEDIATE] = true; //laterFunc[Symbol.for('BANG-VV')] = true; return laterFunc; } } export function c(p,...v) { //console.error(`Using c (X) function. Not recommended`); return vanillaview(p,v, {useCache:false}); } // main function (TODO: should we refactor?) function vanillaview(p,v,{useCache:useCache=true, _host}={}) { const retVal = {}; let instance, cacheKey; v = v.map(guardAndTransformVal); if ( useCache ) { (instance = (v.find(isKey) || {})); cacheKey = p.join(JOINER); const {cached,firstCall} = isCached(cacheKey,v,instance); if ( ! firstCall ) { cached.update(v); return cached; } else { retVal.oldVals = Array.from(v); } } else { retVal.oldVals = Array.from(v); } // compile the template into an updater p = [...p]; const vmap = {}; const V = v.map(replaceValWithKeyAndOmitInstanceKey(vmap)); const externals = []; let str = EMPTY; while( p.length > 1 ) str += p.shift() + V.shift(); str += p.shift(); const frag = toDOM(str); // FIXME: do we need to walk through shadows here? const walker = document.createTreeWalker(frag, NodeFilter.SHOW_ALL); do { makeUpdaters({walker,vmap,externals}); } while(walker.nextNode()) Object.assign(retVal, { externals, v:Object.values(vmap), cacheKey, instance, to, update, code:CODE, nodes:Array.from(frag.childNodes) }); if ( useCache ) { if ( instance.key !== undefined ) { cache[cacheKey].instances[instance.key] = retVal; } else { cache[cacheKey] = retVal; } retVal.nodes.forEach(node => { const instanceKey = instance.key+EMPTY; REMOVE_MAP.set(node, {ck:cacheKey, ik: instanceKey}); _host.destructors.add(() => { DEBUG && console.log(`Destructor running for ${_host.name} to remove vv cache keys`, {cacheKey, instanceKey}); if ( cacheKey && instanceKey && instanceKey !== "undefined" ) { if ( cache[cacheKey] ) { cache[cacheKey].instances[instanceKey] = null; } } else if ( cacheKey ) { cache[cacheKey] = null; } }); }); } return retVal; } // bang integration functions (modified from bang versions) async function process(that, x, state, _host) { if ( typeof x === 'string' ) return x; else if ( typeof x === 'number' ) return x+EMPTY; else if ( typeof x === 'boolean' ) return x+EMPTY; else if ( x instanceof Date ) return x+EMPTY; else if ( isUnset(x) ) { if ( CONFIG.allowUnset ) return CONFIG.unsetPlaceholder || EMPTY; else { throw new TypeError(`Value cannot be unset, was: ${x}`); } } else if ( x instanceof Promise ) return await process(that, await x.catch(err => err+EMPTY), state, _host); else if ( x instanceof Element ) return x.outerHTML; else if ( x instanceof Node ) return x.textContent; else if ( isVV(x) ) { return {code:CODE, externals: x.externals, nodes: x.nodes}; } const isArray = Array.isArray(x); const isVVArray = isArray && (x.length === 0 || isVV(x[0])); if ( isIterable(x) ) { if ( isVVArray ) { return join(x); // is a func array ? } else if ( (x[0] instanceof Function) && ! x[0][IMMEDIATE] ) { const character = funcCharacter(...x); if ( _host.names.has(character) ) { const {func: existingFunc, name: existingName} = _host.names.get(character); if ( existingName ) { DEBUG && console.log(`Name exists!`, x, existingName); DEBUG && console.log(`Adding ${existingName} adder`, existingFunc); _host.funcs.add(component => (component[existingName] = component[existingName] || existingFunc, existingName)); return existingName; } } const randomName = NextFunc(); DEBUG && console.log({definedFunction: randomName, source: 1}); const func = ( function(ev) { for( const fun of x ) { try { fun(ev); } catch(e) { console.warn(`Handler in func array failed`, {fun, e, ev, x}); } } } ); _host.names.set(character, {name:randomName, func}); DEBUG && console.log(`Adding ${randomName} adder`, func, _host); _host.funcs.add(component => (component[randomName] = func, randomName)); DEBUG && console.log('name', randomName, func); return `${randomName}(event)`; } else if ( x[0] instanceof Element || x[0] instanceof Node ) { return {code:CODE, externals: [], nodes: x}; } else { // if an Array or iterable is given then // its values are recursively processed via this same function return process(that, await Promise.all( ( await Promise.all(Array.from(x)).catch(e => e+EMPTY) ).map(v => process(that, v, state, _host)) ), state, _host); } } const isVVK = isKey(x); const isMAO = x.code === CODE && typeof x.str === "string"; if ( isVVK || isMAO || isVV(x) ) { return x; // let vanillaview guardAndTransformVal handle } else if ( x[IMMEDIATE] && Object.getPrototypeOf(x).constructor.name === 'AsyncFunction' ) { return await process(that, await x(state, _host), state, _host); } else if ( x[IMMEDIATE] && (x instanceof Function) ) return x(state, _host); else // it's an object, of some type if ( x instanceof Function ) { const character = funcCharacter(x); if ( _host.names.has(character) ) { const {func: existingFunc, name: existingName} = _host.names.get(character); if ( existingName ) { DEBUG && console.log(`Name exists!`, x, existingName); DEBUG && console.log(`Adding ${existingName} adder`, existingFunc); _host.funcs.add(component => (component[existingName] = component[existingName] || existingFunc, existingName)); return existingName; } } const name = NextFunc(); _host.names.set(character, {name, func:x}); DEBUG && console.log(`Adding ${name} adder`, x, _host); _host.funcs.add(component => (component[name] = x, name)); DEBUG && console.log({definedFunction:name, source: 2}); DEBUG && console.log('name', name, x); return `${name}(event)`; } else { // State store /* so we assume an object is state and save it */ /* to the global state store */ /* which is two-sides so we can find a key */ /* given an object. This avoid duplicates */ let stateKey; // own keys // an object can specify it's own state key // to provide a single logical identity for a piece of state that may // be represented by many objects if ( Object.prototype.hasOwnProperty.call(x, CONFIG.bangKey) ) { stateKey = new that.StateKey(x[CONFIG.bangKey])+EMPTY; // in that case, replace the previously saved object with the same logical identity const oldX = that.STATE.get(stateKey); that.STATE.delete(oldX); that.STATE.set(stateKey, x); that.STATE.set(x, stateKey); } else /* or the system can come up with a state key */ { const jsx = JS(x) if ( that.STATE.has(x) || that.STATE.has(jsx) ) { stateKey = (that.STATE.get(x) || that.STATE.get(jsx)).replace(/.json.last$/,''); const lastXJSON = that.STATE.get(stateKey+'.json.last'); if ( jsx !== lastXJSON ) { that.STATE.delete(lastXJSON); if ( stateKey.startsWith('system-key') ) { that.STATE.delete(stateKey); const oKey = stateKey; stateKey = new that.StateKey()+EMPTY; } that.STATE.set(stateKey, x); that.STATE.set(x, stateKey); } } else { const oKey = stateKey; stateKey = new that.StateKey()+EMPTY; //console.log({oKey, stateKey, block2:true, jsx}); that.STATE.set(stateKey, x); that.STATE.set(x, stateKey); /* _host.funcs.add(component => { let aq = Dependents.get(stateKey); if ( ! aq ) { aq = new Set(); Dependents.set(stateKey, aq); } aq.add(component); }); */ } that.STATE.set(jsx, stateKey+'.json.last'); that.STATE.set(stateKey+'.json.last', jsx); } stateKey += EMPTY; return stateKey; } } function funcCharacter(...x) { return `${x.map(f => f.toString()).join(';')}`; } function isIterable(y) { if ( y === null ) return false; return y[Symbol.iterator] instanceof Function; } function isUnset(x) { return x === undefined || x === null; } // to function function to(location, options) { const position = (options || 'replace').toLocaleLowerCase(); const frag = document.createDocumentFragment(); this.nodes.forEach(n => frag.appendChild(n)); const isNode = location instanceof Node; const elem = isNode ? location : document.querySelector(location); try { MOVE[position](frag,elem); } catch(e) { DEBUG && console.warn(e); switch(e.constructor && e.constructor.name) { case "DOMException": die({error: INSERT()},e); break; case "TypeError": die({error: NOTFOUND(location)},e); break; default: throw e; } } while(this.externals.length) { this.externals.shift()(); } } // update functions function makeUpdaters({walker,vmap,externals}) { const node = walker.currentNode; if ( node.shadowRoot instanceof ShadowRoot ) { throw new TypeError(`Shadow not supported here currently`); } switch( node.nodeType ) { case Node.ELEMENT_NODE: handleElement({node,vmap,externals}); break; case Node.COMMENT_NODE: case Node.TEXT_NODE: handleNode({node,vmap,externals}); break; } } // debug stuff const Replacers = new Map(); if ( DEBUG ) { window.Replacers = Replacers; } // end debug stuff function handleNode({node,vmap,externals}) { const lengths = []; const text = node.nodeValue; let result = KEYMATCH.exec(text); while ( result ) { const {index} = result; const key = result[1]; const val = vmap[key]; const replacer = makeNodeUpdater({node,index,lengths,val}); const wrappedReplacer = () => { DEBUG && console.group(`Replacer calling for ${key}`); try { replacer(val.val); } catch(error) { console.warn(`Error in replacer for key ${key}`, {val, error}); } DEBUG && console.log(`Replacer called for ${key}`); Replacers.delete(key); DEBUG && console.groupEnd(); }; externals.push(wrappedReplacer); Replacers.set(key, wrappedReplacer); val.replacers.push( replacer ); result = KEYMATCH.exec(text); } } // node functions function makeNodeUpdater(nodeState) { const {node} = nodeState; const scope = Object.assign({}, nodeState, { oldVal: {length: KEYLEN}, oldNodes: [node], lastAnchor: node, }); return (newVal) => { if ( scope.oldVal == newVal ) return; scope.val.val = newVal; switch(getType(newVal)) { case "vanillaviewobject": handleMarkupInNode(newVal, scope); break; default: handleTextInNode(newVal, scope); break; } }; } function handleMarkupInNode(newVal, state) { let {oldNodes,lastAnchor} = state; if ( newVal.nodes.length ) { if ( sameOrder(oldNodes,newVal.nodes) ) { // do nothing } else { // perf // list updates could be possible more efficient // this is if we are inserting new nodes // but I can't imagine a way to do it that's not: // 1) quadratic (edit distance) // 2) lots of heuristics (did we insert a new node or nodes at the front? OK...etc...) const LEGACY = false; if ( LEGACY ) { Array.from(newVal.nodes).reverse().forEach(n => { lastAnchor.parentNode.insertBefore(n,lastAnchor.nextSibling); state.lastAnchor = lastAnchor.nextSibling; }); state.lastAnchor = newVal.nodes[0]; } else { const insertable = []; Array.from(newVal.nodes).forEach(node => { const inserted = document.contains(node.ownerDocument); if ( ! inserted ) { insertable.push(node); } else { while( insertable.length ) { const insertee = insertable.shift(); node.parentNode.insertBefore(insertee, node); } } }); while ( insertable.length ) { const insertee = insertable.shift(); lastAnchor.parentNode.insertBefore(insertee,lastAnchor); } state.lastAnchor = newVal.nodes[newVal.nodes.length-1]; } } } else { const placeholderNode = summonPlaceholder(lastAnchor); lastAnchor.parentNode.insertBefore(placeholderNode,lastAnchor.nextSibling); state.lastAnchor = placeholderNode; } // MARK: Unbond event might be relevant here. // if nodes are not included we can just remove them const dn = diffNodes(oldNodes,newVal.nodes); if ( dn.size ) { const f = document.createDocumentFragment(); const killSet = new Set(); dn.forEach(n => { f.appendChild(n); if ( n.linkedCustomElement ) { f.appendChild(n.linkedCustomElement); } if ( n.nodeType === Node.COMMENT_NODE && n.textContent.match(/key\d+/) ) return; const kill = REMOVE_MAP.get(n); if ( kill ) { killSet.add(JS(kill)); // NOTE: // this next line is essential // it checks which other VV fragments are descendents of the node being removed. And for each of those // it adds the cache Keys of that fragment to the kill set, so their caches will also be killed // this essential line prevents the re-rendering of cached components that are meant to be on-screen into // off-screen detached fragments, which occurs if we don't kill these caches, because their caches // would indicate they need to be re-rendered at their insertion point, instad of re-created anew const deps = [...REMOVE_MAP.entries()].forEach(([vvNode, k]) => n.contains(vvNode) && killSet.add(JS(k))); } else { DEBUG && console.warn(`No kill signature for`, n, REMOVE_MAP); } }); killSet.forEach(kill => { const {ck: cacheKey, ik: instanceKey} = JSON.parse(kill); try { if ( cacheKey && instanceKey && instanceKey !== "undefined" ) { if ( cache[cacheKey] ) { cache[cacheKey].instances[instanceKey] = null; } } else if ( cacheKey ) { cache[cacheKey] = null; } } catch(e) { console.warn(`Error in kill for`, {kill, cacheKey, instanceKey}); } }); } state.oldNodes = newVal.nodes || [lastAnchor]; while ( newVal.externals.length ) { const func = newVal.externals.shift(); func(); } } function sameOrder(nodesA, nodesB) { if ( nodesA.length != nodesB.length ) return false; return Array.from(nodesA).every((an,i) => an == nodesB[i]); } function handleTextInNode(newVal, state) { let {oldVal, index, val, lengths, node} = state; const valIndex = val.vi; const originalLengthBefore = Object.keys(lengths.slice(0,valIndex)).length*KEYLEN; const lengthBefore = lengths.slice(0,valIndex).reduce((sum,x) => sum + x, 0); const value = node.nodeValue; lengths[valIndex] = newVal.length; const correction = lengthBefore-originalLengthBefore; const before = value.slice(0,index+correction); const after = value.slice(index+correction+oldVal.length); const newValue = before + newVal + after; node.nodeValue = newValue; if ( node.linkedCustomElement && newValue !== oldVal ) { updateLinkedCustomElement(node); } state.oldVal = newVal; } // element attribute functions function updateLinkedCustomElement(node) { const lce = node.linkedCustomElement; const span = toDOM(`<span ${node.textContent}></span>`).firstChild; //FIXME: may have to look at this for the combination of vv and bang, may not need to remove these const toRemove = new Set( getAttributes(lce) .filter(({name}) => !name.startsWith('on')) .map(({name}) => name) ); getAttributes(span).forEach(({name, value}) => { if ( name === lce.localName ) return; // i.e., it's the bang tag name if ( name.startsWith('on') ) return; // we don't handle event handlers here, that's in bang lce.setAttribute(name, value); toRemove.delete(name); }); toRemove.forEach(name => lce.removeAttribute(name)); } function handleElement({node,vmap,externals}) { getAttributes(node).forEach(({name,value} = {}) => { const attrState = {node, vmap, externals, name, lengths: []}; KEYMATCH.lastIndex = 0; let result = KEYMATCH.exec(name); while( result ) { prepareAttributeUpdater(result, attrState, {updateName:true}); result = KEYMATCH.exec(name); } KEYMATCH.lastIndex = 0; result = KEYMATCH.exec(value); while( result ) { prepareAttributeUpdater(result, attrState, {updateName:false}); result = KEYMATCH.exec(value); } }); } function prepareAttributeUpdater(result, attrState, {updateName}) { const {index, input} = result; const scope = Object.assign({}, attrState, { index, input, updateName, val: attrState.vmap[result[1]], oldVal: {length: KEYLEN}, oldName: attrState.name, }); let replacer; if ( updateName ) { replacer = makeAttributeNameUpdater(scope); } else { replacer = makeAttributeValueUpdater(scope); } scope.externals.push(() => replacer(scope.val.val)); scope.val.replacers.push( replacer ); } // FIXME: needs to support multiple replacements just like value // QUESTION: why is the variable oldName so required here, why can't we call it oldVal? // if we do it breaks, WHY? function makeAttributeNameUpdater(scope) { let {oldName,node,val} = scope; return (newVal) => { if ( oldName == newVal ) return; val.val = newVal; const attr = node.hasAttribute(oldName) ? oldName : EMPTY if ( attr !== newVal ) { if ( attr ) { node.removeAttribute(oldName); node[oldName] = undefined; } if ( newVal ) { newVal = newVal.trim(); let name = newVal, value = undefined; if( ATTRMATCH.test(newVal) ) { const assignmentIndex = newVal.indexOf('='); ([name,value] = [newVal.slice(0,assignmentIndex), newVal.slice(assignmentIndex+1)]); } reliablySetAttribute(node, name, value); } oldName = newVal; } }; } function makeAttributeValueUpdater(scope) { const updater = (newVal) => { if ( scope.oldVal == newVal ) return; scope.val.val = newVal; switch(getType(newVal)) { case "funcarray": updateAttrWithFuncarrayValue(newVal, scope); break; case "function": updateAttrWithFunctionValue(newVal, scope); break; case "handlers": updateAttrWithHandlersValue(newVal, scope); break; case "vanillaviewobject": newVal = nodesToStr(newVal.nodes); updateAttrWithTextValue(newVal, scope); break; /* eslint-disable no-fallthrough */ case "markupattrobject": // deliberate fall through newVal = newVal.str; default: updateAttrWithTextValue(newVal, scope); break; /* eslint-enable no-fallthrough */ } }; // call it the first time so it loads well // and we elide out the key placeholders here updater(scope.val.val); return updater; } // helpers function Replacer(key, value) { const obj = this; if ( typeof obj[key] === "function" ) { return value.toString(); } else if ( value instanceof Node ) { return `${value.nodeName}//${value.nodeValue || value.outerHTML || value.textContent}`; } else return value; } function getAttributes(node) { if ( ! node.hasAttribute ) return []; // for parity with classList.add (which trims whitespace) // otherwise once the classList manipulation happens // our indexes for replacement will be off if ( node.hasAttribute('class') ) { node.setAttribute('class', formatClassListValue(node.getAttribute('class'))); } return Array.from(node.attributes); /* if ( !! node.attributes && Number.isInteger(node.attributes.length) ) return Array.from(node.attributes); const attrs = []; for ( const name of node ) { if ( node.hasAttribute(name) ) { attrs.push({name, value:node.getAttribute(name)}); } } return attrs; */ } function updateAttrWithFunctionValue(newVal, scope) { let {oldVal,node,name,externals} = scope; if ( name !== 'bond' ) { let flags = {}; if ( name.includes(':') ) { ([name, ...flags] = name.split(':')); flags = flags.reduce((O,f) => { O[f] = true; return O; }, {}); } if ( oldVal ) { node.removeEventListener(name, oldVal, flags); } node.addEventListener(name, newVal, flags); reliablySetAttribute(node, name, EMPTY); } else { if ( oldVal ) { const index = externals.indexOf(oldVal); if ( index >= 0 ) { externals.splice(index,1); } } externals.push(() => newVal(node)); } scope.oldVal = newVal; } function updateAttrWithFuncarrayValue(newVal, scope) { let {oldVal,node,name,externals} = scope; if ( oldVal && ! Array.isArray(oldVal) ) { oldVal = [oldVal]; } if ( name !== 'bond' ) { let flags = {}; if ( name.includes(':') ) { ([name, ...flags] = name.split(':')); flags = flags.reduce((O,f) => { O[f] = true; return O; }, {}); } if ( oldVal ) { oldVal.forEach(of => node.removeEventListener(name, of, flags)); } newVal.forEach(f => node.addEventListener(name, f, flags)); } else { if ( oldVal ) { oldVal.forEach(of => { const index = externals.indexOf(of); if ( index >= 0 ) { externals.splice(index,1); } }); } newVal.forEach(f => externals.push(() => f(node))); } scope.oldVal = newVal; } function updateAttrWithHandlersValue(newVal, scope) { let {oldVal,node,externals,} = scope; if ( !!oldVal && typeof oldVal === 'object' ) { Object.entries(oldVal).forEach(([eventName,funcVal]) => { if ( eventName !== 'bond' ) { let flags = {}; if ( eventName.includes(':') ) { ([eventName, ...flags] = eventName.split(':')); flags = flags.reduce((O,f) => { O[f] = true; return O; }, {}); } node.removeEventListener(eventName, funcVal, flags); } else { const index = externals.indexOf(funcVal); if ( index >= 0 ) { externals.splice(index,1); } } }); } Object.entries(newVal).forEach(([eventName,funcVal]) => { if ( eventName !== 'bond' ) { let flags = {}; if ( eventName.includes(':') ) { ([eventName, ...flags] = eventName.split(':')); flags = flags.reduce((O,f) => { O[f] = true; return O; }, {}); } node.addEventListener(eventName, funcVal, flags); } else { externals.push(() => funcVal(node)); } }); scope.oldVal = newVal; } function updateAttrWithTextValue(newVal, scope) { let {oldVal,node,index,name,val,lengths,oldAttrVal} = scope; let attr = node.getAttribute(name); let newAttrValue; if ( oldAttrVal === oldVal ) { // if we are setting old val to be the whole attribute value // then we can just splice it in by setting it easily newAttrValue = newVal; } else { // otherwise we need to carefully calculate everything let zeroWidthCorrection = 0; const valIndex = val.vi; const originalLengthBefore = Object.keys(lengths.slice(0,valIndex)).length*KEYLEN; // we need to trim newVal to have parity with classlist add // the reason we have zeroWidthCorrection = -1 // is because the classList is a set of non-zero width tokens // separated by spaces // when we have a zero width token, we have two adjacent spaces // which, by virtue of our other requirement, gets replaced by a single space // effectively elliding out our replacement location // in order to keep our replacement location in tact // we need to compensate for the loss of a token slot (effectively a token + a space) // and having a -1 correction effectively does this. if ( name == "class" ) { newVal = newVal.trim(); if ( newVal.length == 0 ) { zeroWidthCorrection = -1; } scope.val.val = newVal; } lengths[valIndex] = newVal.length + zeroWidthCorrection; const lengthBefore = lengths.slice(0,valIndex).reduce((sum,x) => sum + x, 0); const correction = lengthBefore-originalLengthBefore; const before = attr.slice(0,index+correction); const after = attr.slice(index+correction+oldVal.length); if ( name == "class" ) { const spacer = oldVal.length == 0 ? ' ' : EMPTY; newAttrValue = before + spacer + newVal + spacer + after; } else { newAttrValue = before + newVal + after; } } if ( attr !== newAttrValue ) { reliablySetAttribute(node, name, newAttrValue); } scope.oldVal = newVal; scope.oldAttrVal = newAttrValue; } function reliablySetAttribute(node, name, value, /*{funcValue} = {}*/) { if ( name == "class" ) { value = formatClassListValue(value); } const oName = name; let modifiers; if ( modifiers ) { modifiers = Object.fromEntries(modifiers.map(m => ([m, true]))); DEBUG && console.warn("not handling modifiers currently", {node, name, value, modifiers}); //node.addEventListener(name, funcValue, modifiers); } if ( CONFIG.EVENTS.includes('on'+name) ) { name = 'on'+name; const existingValue = node.getAttribute(name); if ( node.getRootNode().host ) { //console.log(node, [...node.getRootNode().host.paths.keys()]); if ( node.getRootNode().host.paths.has(existingValue) ) { DEBUG && console.log('Not running replacement again for', {node, name, oName, value, existingValue}); return; } } else { DEBUG && console.warn(`No host exists yet`); if ( existingValue?.startsWith('this.') ) { DEBUG && console.log('Not running replacement again for', {node, name, oName, value, existingValue}); return; } } } try { node.setAttribute(name,isUnset(value) ? name : value); } catch(e) { console.warn(`error`, e, {node, name, value}); } // if you set style like this is fucks it up if ( name in node && name !== 'style' ) { try { node[name] = isUnset(value) ? true : value; } catch(e) { } } } function getType(val) { const to = typeof val; const type = to === 'function' ? 'function' : val.code === CODE && Array.isArray(val.nodes) ? 'vanillaviewobject' : val.code === CODE && typeof val.str === 'string' ? 'markupattrobject' : Array.isArray(val) && (val.length === 0 || ( val[0].code === CODE && Array.isArray(val[0].nodes) )) ? 'vanillaviewarray' : Array.isArray(val) && (val.length === 0 || ( typeof val[0] === 'function' )) ? 'funcarray' : to === 'object' ? 'handlers' : 'default' ; return type; } function summonPlaceholder(sibling) { let ph = [...sibling.parentNode.childNodes].find( node => node.isConnected && node.nodeType == Node.COMMENT_NODE && node.nodeValue == 'vanillaview_placeholder' ); if ( ! ph ) { ph = toDOM(`<!--vanillaview_placeholder-->`).firstChild; } return ph; } // cache helpers // FIXME: function needs refactor function isCached(cacheKey,v,instance) { let firstCall; let cached = cache[cacheKey]; if ( cached == undefined ) { cached = cache[cacheKey] = {}; if ( instance.key !== undefined ) { cached.instances = {}; cached = cached.instances[instance.key] = {}; } firstCall = true; } else { if ( instance.key !== undefined ) { if ( ! cached.instances ) { cached.instances = {}; firstCall = true; } else { cached = cached.instances[instance.key]; if ( ! cached ) { firstCall = true; } else { if ( instance.kill === true ) { cached = cache[cacheKey]; if ( cached && cached.instances ) { cached.instances[instance.key] = null; } cached = null; firstCall = true; } else { firstCall = false; } } } } else { firstCall = false; } } //console.log({cached,firstCall,instance}); return {cached,firstCall}; } // Markup helpers // Returns an object that VanillaView treats as markup, // even tho it is NOT a VanillaView Object (defined with R/X/$) // And even tho it is in the location of a template value replacement // Which would normally be the treated as String function markup(str) { str = isUnset(str) ? EMPTY : str; const frag = toDOM(str); const retVal = { type: 'MarkupObject', code:CODE, nodes:Array.from(frag.childNodes), externals: [] }; return retVal; } // Returns an object that VanillaView treats, again, as markup // But this time markup that is OKAY to have within a quoted attribute function attrmarkup(str) { str = isUnset(str) ? EMPTY : str; str = str.replace(/"/g,'&quot;'); const retVal = { type: 'MarkupAttrObject', code: CODE, str }; return retVal; } function guardEmptyHandlers(val) { if ( Array.isArray(val) ) { if ( val.length == 0 ) { return [NULLFUNC] } return val; } else { if ( isUnset(val) ) { return NULLFUNC; } } } // other helpers function formatClassListValue(value) { value = value.trim(); value = value.replace(/\s+/g, ' '); return value; } function replaceValWithKeyAndOmitInstanceKey(vmap) { return (val,vi) => { // omit instance key if ( isKey(val) ) { return EMPTY; } const key = 'key'+Math.random().toFixed(15); let k = key; if ( val.code === CODE && Array.isArray(val.nodes) ) { k = `<!--${k}-->`; } vmap[key] = {vi,val,replacers:[]}; return k; }; } function toDOM(str) { DIV.replaceChildren(); DIV.insertAdjacentHTML(POS, `<template>${str}</template>`); return DIV.firstElementChild.content; } function guardAndTransformVal(v) { const isVVArray = Array.isArray(v) && (v.length === 0 || isVV(v[0])); const isNotSet = isUnset(v); const isForgery = v.code !== CODE && Array.isArray(v.nodes); const isObject = typeof v === 'object'; if ( isVVArray ) return join(v); if ( isKey(v) ) return v; if ( v.code === CODE ) return v; if ( isNotSet ) die({error: UNSET()}); if ( isForgery ) die({error: XSS()}); if ( Array.isArray(v) && v[0] instanceof Node ) { return {code:CODE, nodes: v, externals: []}; } if ( Array.isArray(v) && v[0] instanceof Function ) { return v; } if ( isObject ) die({error: OBJ(v)}); return v+EMPTY; } function join(os) { const externals = []; const bigNodes = []; const v = []; const oldVals = []; for( const o of os ) { //v.push(...o.v); //oldVals.push(...o.oldVals); externals.push(...o.externals); bigNodes.push(...o.nodes); } const retVal = {v,code:CODE,oldVals,nodes:bigNodes,to,update,externals}; return retVal; } function nodesToStr(nodes) { const frag = document.createDocumentFragment(); nodes.forEach(n => frag.appendChild(n.cloneNode(true))); const container = document.createElement('body'); container.appendChild(frag); return container.innerHTML; } function diffNodes(last,next) { last = new Set(last); next = new Set(next); return new Set([...last].filter(n => !next.has(n))); } function update(newVals) { const updateable = this.v.filter(({vi}) => didChange(newVals[vi], this.oldVals[vi])); updateable.forEach(({vi,replacers}) => replacers.forEach(f => f(newVals[vi]))); this.oldVals = Array.from(newVals); } function didChange(oldVal, newVal) { const [oldType, newType] = [oldVal, newVal].map(getType); let ret; if ( oldType != newType ) { ret = true; } else { switch(oldType) { case "vanillaviewobject": // the vanillaview object is returned by a view function // which has already called its updaters and checked its slot values // to determine and show changes // except in the case of a list of nodes ret = true; break; /* eslint-disable no-fallthrough */ case "funcarray": case "function": // hard to equate even if same str value as scope could be diff ret = true; break; case "vanillaviewarray": // need to do array dif so don't do here ret = true; break; case "markupattrobject": // need to check multiple things ret = true; break; default: ret = JS(oldVal) !== JS(newVal); break; /* eslint-enable no-fallthrough */ } } return ret; } // reporting and error helpers function die(msg,err) { msg.stack = ((DEBUG && err) || new Error()).stack.split(/\s*\n\s*/g); throw msg; }