diff --git a/src/custom-element.js b/src/custom-element.js index ece2a69..0176b52 100644 --- a/src/custom-element.js +++ b/src/custom-element.js @@ -1,27 +1,36 @@ -const XML_DECLARATION = '' -, XSL_NS_URL = 'http://www.w3.org/1999/XSL/Transform' -, DCE_NS_URL ="urn:schemas-epa-wg:dce"; +const XSL_NS_URL = 'http://www.w3.org/1999/XSL/Transform' +, HTML_NS_URL = 'http://www.w3.org/1999/xhtml' +, EXSL_NS_URL = 'http://exslt.org/common' +, DCE_NS_URL ="urn:schemas-epa-wg:dce"; // const log = x => console.debug( new XMLSerializer().serializeToString( x ) ); -const attr = (el, attr)=> el.getAttribute(attr) +const attr = (el, attr)=> el.getAttribute?.(attr) +, isText = e => e.nodeType === 3 , create = ( tag, t = '' ) => ( e => ((e.innerText = t||''),e) )(document.createElement( tag )) -, createNS = ( ns, tag, t = '' ) => ( e => ((e.innerText = t||''),e) )(document.createElementNS( ns, tag )); +, createText = ( d, t) => (d.ownerDocument || d ).createTextNode( t ) +, createNS = ( ns, tag, t = '' ) => ( e => ((e.innerText = t||''),e) )(document.createElementNS( ns, tag )) +, xslNs = x => ( x?.setAttribute('xmlns:xsl', XSL_NS_URL ), x ) +, xslHtmlNs = x => ( x?.setAttribute('xmlns:xhtml', HTML_NS_URL ), xslNs(x) ); + function +ASSERT(x) +{ if(!x) + debugger +} function xml2dom( xmlString ) { - return new DOMParser().parseFromString( XML_DECLARATION + xmlString, "application/xml" ) + return new DOMParser().parseFromString( xmlString, "application/xml" ) } function xmlString(doc){ return new XMLSerializer().serializeToString( doc ) } function injectData( root, sectionName, arr, cb ) -{ +{ const create = ( tag ) => root.ownerDocument.createElement( tag ); const inject = ( tag, parent, s ) => - { - parent.append( s = createNS( DCE_NS_URL, tag ) ); + { parent.append( s = create( tag ) ); return s; }; const l = inject( sectionName, root ); @@ -71,70 +80,133 @@ Json2Xml( o, tag ) ret.push("/>"); return ret.join('\n'); } + export function +tagUid( node ) +{ // {} to xsl:value-of + forEach$(node,'*',d => [...d.childNodes].filter( e=>e.nodeType === 3 ).forEach( e=> + { if( e.parentNode.localName === 'style' ) + return; + const m = e.data.matchAll( /{([^}]*)}/g ); + if(m) + { let l = 0 + , txt = t => createText(e,t||'') + , tt = []; + [...m].forEach(t=> + { if( t.index > l ) + tt.push( txt( t.input.substring( l, t.index ) )) + const v = e.ownerDocument.createElement('xsl:value-of'); + v.setAttribute('select', t[1] ); + tt.push(v); + l = t.index+t[0].length; + }) + if( l < e.data.length) + tt.push( txt( e.data.substring(l,e.data.length) )); + if( tt.length ) + { for( let t of tt ) + d.insertBefore(t,e); + d.removeChild(e); + } + } + })); + if( 'all' in node ) { + let i= 1; + for( let e of node.all ) + e.setAttribute && !e.tagName.startsWith('xsl:') && e.setAttribute('data-dce-id', '' + i++) + } + return node +} export function createXsltFromDom( templateNode, S = 'xsl:stylesheet' ) { if( templateNode.tagName === S || templateNode.documentElement?.tagName === S ) - return templateNode - const dom = xml2dom( -` + + + + + + + + + + + + + `) + const sanitizeProcessor = new XSLTProcessor() + , tc = (n => + { + forEach$(n,'script', s=> s.remove() ); + const e = n.firstElementChild?.content || n.content + , asXmlNode = r => xslHtmlNs(xml2dom( '' ).importNode(r, true)); + if( e ) + { const t = create('div'); + [ ...e.childNodes ].map( c => t.append(c.cloneNode(true)) ) + return asXmlNode(t) + } + return asXmlNode(n.documentElement || n.body || n) + })(templateNode) + , xslDom = xml2dom( + ` - - + - - \t - + - + - - + ` - ); + ); - const attrsTemplate = dom.documentElement.lastElementChild.previousElementSibling - , getTemplateRoot = n => n.documentElement || n.firstElementChild?.content || n.content || n.body || n - , tc = getTemplateRoot(templateNode) - , cc = tc?.childNodes || []; - if( (tc instanceof CustomElement) || tc.nodeType===11) { - for( let c of cc ) - attrsTemplate.append(dom.importNode(c,true)) - }else - { - attrsTemplate.append(dom.importNode(tc,true)) - } + sanitizeProcessor.importStylesheet( sanitizeXsl ); + + const fr = sanitizeProcessor.transformToFragment(tc, document) + , $ = (e,css) => e.querySelector(css) + , payload = $( xslDom, 'template[mode="payload"]'); + if( !fr ) + return console.error("transformation error",{ xml:tc.outerHTML, xsl: xmlString( sanitizeXsl ) }); + + for( const c of fr.childNodes ) + payload.append(xslDom.importNode(c,true)) + + const embeddedTemplates = [...payload.querySelectorAll('template')]; + embeddedTemplates.forEach(t=>payload.ownerDocument.documentElement.append(t)); - const slot2xsl = s => - { const v = dom.firstElementChild.lastElementChild.lastElementChild.cloneNode(true); - v.firstElementChild.setAttribute('select',`'${s.name}'`) + const slotCall = $(xslDom,'call-template[name="slot"]') + , slot2xsl = s => + { const v = slotCall.cloneNode(true) + , name = attr(s,'name') || ''; + name && v.firstElementChild.setAttribute('select',`'${ name }'`) for( let c of s.childNodes) v.lastElementChild.append(c) return v } - for( const s of attrsTemplate.querySelectorAll('slot') ) - s.parentNode.replaceChild( slot2xsl(s), s ) + forEach$( payload,'slot', s => s.parentNode.replaceChild( slot2xsl(s), s ) ) - // apply bodyXml changes - return dom + return tagUid(xslDom) } export async function xhrTemplate(src) @@ -175,9 +247,9 @@ deepEqual(a, b, O=false) injectSlice( x, s, data ) { const isString = typeof data === 'string' ; - + const createXmlNode = ( tag, t = '' ) => ( e => ((e.append( createText(x, t||''))),e) )(x.ownerDocument.createElement( tag )) const el = isString - ? create(s, data) + ? createXmlNode(s, data) : document.adoptNode( xml2dom( Json2Xml( data, s ) ).documentElement); [...x.children].filter( e=>e.localName === s ).map( el=>el.remove() ); el.data = data @@ -186,8 +258,7 @@ injectSlice( x, s, data ) function forEach$( el, css, cb){ if( el.querySelectorAll ) - for( let n of el.querySelectorAll(css) ) - cb(n) + [...el.querySelectorAll(css)].forEach(cb) } const getByHashId = ( n, id )=> ( p => n===p? null: (p && ( p.querySelector(id) || getByHashId(p,id) ) ))( n.getRootNode() ) const loadTemplateRoots = async ( src, dce )=> @@ -216,6 +287,64 @@ const loadTemplateRoots = async ( src, dce )=> return [dom] }catch (error){ return [dce]} } +export function mergeAttr( from, to ) +{ if( isText(from) ) + { + if( !isText(to) ){ debugger } + return + } + for( let a of from.attributes) + a.namespaceURI? to.setAttributeNS( a.namespaceURI, a.name, a.value ) : to.setAttribute( a.name, a.value ) +} +export function assureUnique(n, id=0) +{ + const m = {} + for( const e of n.childNodes ) + { + const a = attr(e,'data-dce-id') || e.dceId || 0; + if( !m[a] ) + { if( !a ) + { m[a] = e.dceId = ++id; + if( e.setAttribute ) + e.setAttribute('data-dce-id', e.dceId ) + }else + m[a] = 1; + }else + { const v = e.dceId = a + '-' + m[a]++; + if( e.setAttribute ) + e.setAttribute('data-dce-id', v ) + } + e.childNodes.length && assureUnique(e) + } +} +export function merge( parent, fromArr ) +{ + const id2old = {}; + for( let c of parent.childNodes) + { ASSERT( !id2old[c.dceId] ); + if( isText(c) ) + { ASSERT( c.data.trim() ); + id2old[c.dceId || 0] = c; + } else + id2old[attr(c, 'data-dce-id') || 0] = c; + } + for( let e of [...fromArr] ) + { + const o = id2old[ attr(e, 'data-dce-id') || e.dceId ]; + if( o ) + { if( isText(e) ) + { if( o.nodeValue !== e.nodeValue ) + o.nodeValue = e.nodeValue; + }else + { mergeAttr(o,e) + if( o.childNodes.length || e.childNodes.length ) + merge(o, e.childNodes) + } + }else + parent.append( e ) + } +} + export class CustomElement extends HTMLElement { @@ -225,7 +354,7 @@ CustomElement extends HTMLElement , templateDocs = templateRoots.map( n => createXsltFromDom( n ) ) , xp = templateDocs.map( (td, p) =>{ p = new XSLTProcessor(); p.importStylesheet( td ); return p }) - Object.defineProperty( this, "xsltString", { get: ()=>xp.map( td => xmlString(td) ).join('\n') }); + Object.defineProperty( this, "xsltString", { get: ()=>templateDocs.map( td => xmlString(td) ).join('\n') }); const tag = attr( this, 'tag' ); const dce = this; @@ -233,13 +362,18 @@ CustomElement extends HTMLElement class DceElement extends HTMLElement { connectedCallback() - { const x = createNS( DCE_NS_URL,'datadom' ); + { const x = xml2dom( '' ).documentElement; + const createXmlNode = ( tag, t = '' ) => ( e => + { if( t ) + e.append( createText( x, t )) + return e; + })(x.ownerDocument.createElement( tag )) injectData( x, 'payload' , this.childNodes, assureSlot ); - injectData( x, 'attributes' , this.attributes, e => create( e.nodeName, e.value ) ); - injectData( x, 'dataset', Object.keys( this.dataset ), k => create( k, this.dataset[ k ] ) ); - const sliceRoot = injectData( x, 'slice', sliceNames, k => create( k, '' ) ); + this.innerHTML=''; + injectData( x, 'attributes' , this.attributes, e => createXmlNode( e.nodeName, e.value ) ); + injectData( x, 'dataset', Object.keys( this.dataset ), k => createXmlNode( k, this.dataset[ k ] ) ); + const sliceRoot = injectData( x, 'slice', sliceNames, k => createXmlNode( k, '' ) ); this.xml = x; - const slices = {}; const sliceEvents=[]; const applySlices = ()=> @@ -249,7 +383,7 @@ CustomElement extends HTMLElement { const s = attr( ev.target, 'slice'); if( processed[s] ) continue; - injectSlice( sliceRoot, s, ev.detail ); + injectSlice( sliceRoot, s, 'object' === typeof ev.detail ? {...ev.detail}: ev.detail ); processed[s] = ev; } Object.keys(processed).length !== 0 && transform(); @@ -271,17 +405,28 @@ CustomElement extends HTMLElement }; const transform = ()=> { - const ff = xp.map( p => p.transformToFragment(x, document) ); - this.innerHTML = ''; + const ff = xp.map( (p,i) => + { const f = p.transformToFragment(x, document) + if( !f ) + console.error( "XSLT transformation error. xsl:\n", xmlString(templateDocs[i]), '\nxml:\n', xmlString(x) ); + return f + }); ff.map( f => - { [ ...f.childNodes ].forEach( e => this.append( e ) ); + { if( !f ) + return; + assureUnique(f) + merge( this, f.childNodes ) + }) + const changeCb = el=>this.onSlice({ detail: el[attr(el,'slice-prop') || 'value'], target: el }) + , hasInitValue = el => el.hasAttribute('slice-prop') || el.hasAttribute('value') || el.value; - forEach$( this,'[slice]', el => - { if( 'function' === typeof el.sliceInit ) - { const s = attr( el,'slice' ); - slices[s] = el.sliceInit( slices[s] ); - } - }) + forEach$( this,'[slice]', el => + { if( !el.dceInitialized ) + { el.dceInitialized = 1; + el.addEventListener( attr(el,'slice-update')|| 'change', ()=>changeCb(el) ) + if( hasInitValue(el) ) + changeCb(el) + } }) }; transform(); diff --git a/src/demo/http-request.html b/src/demo/http-request.html index 9f62d93..c1396ea 100644 --- a/src/demo/http-request.html +++ b/src/demo/http-request.html @@ -35,7 +35,7 @@ description="load the list of pokemons">

Should display 6 image buttons with pokemon name

- diff --git a/src/demo/local-storage.html b/src/demo/local-storage.html index 00e78c7..5d6d9f1 100644 --- a/src/demo/local-storage.html +++ b/src/demo/local-storage.html @@ -1,5 +1,5 @@ - + custom-element Declarative Custom Element implementation demo @@ -41,21 +41,25 @@

Click the fruits button to add into cart

@@ -90,6 +94,7 @@ basket[k] || (basket[k] = 1); localStorage.setItem( k, basket[k] = 1+1*localStorage[k] ) localStorage.setItem( 'basket', JSON.stringify(basket) ); + renderStorage(); } ); const renderStorage = () => diff --git a/src/demo/location-element.html b/src/demo/location-element.html index bf1e984..bc1716b 100644 --- a/src/demo/location-element.html +++ b/src/demo/location-element.html @@ -1,5 +1,6 @@ - + custom-element Declarative Custom Element implementation demo @@ -24,7 +25,7 @@ ? @@ -74,7 +71,7 @@ -

Has to produce URL properties

@@ -84,24 +81,26 @@ - - - - - - - - - params - - - - - - - - - + + +

URL properties

+ + + {name()} + {.} + + +
+ +

URL parameters

+ + + {name()} + {.} + + +
+
? @@ -119,24 +118,26 @@ - - - - - - - - - params - - - - - - - - - + + +

URL properties

+ + + {name()} + {.} + + +
+ +

URL parameters

+ + + {name()} + {.} + + +
+
? diff --git a/src/http-request.js b/src/http-request.js index 30fcab8..4649c62 100644 --- a/src/http-request.js +++ b/src/http-request.js @@ -2,58 +2,49 @@ const attr = (el, attr)=> el.getAttribute(attr); export class HttpRequestElement extends HTMLElement { - // @attribute url - constructor() { - super(); + static get observedAttributes() { + return [ 'value' // populated from localStorage, if defined initially, sets the value in storage + , 'slice' + , 'url' + , 'method' + , 'header-accept' + ] } + get requestHeaders() { const ret = {}; [...this.attributes].filter(a=>a.name.startsWith('header-')).map( a => ret[a.name.substring(7)] = a.value ); - return ret; + return ret } get requestProps() { const ret = {}; [...this.attributes].filter(a=>!a.name.startsWith('header-')).map( a => ret[a.name] = a.value ); - return ret; + return ret } - sliceInit( s ) - { if( !s ) - s = {}; - s.element = this; - if( s.destroy ) - return s; - const controller = new AbortController(); - s.destroy = ()=> - { // todo destroy slices in custom-element - controller.abort(); - }; + + disconnectedCallback(){ this._destroy?.(); } + + connectedCallback() + { const controller = new AbortController(); + this._destroy = ()=> controller.abort(this.localName+' disconnected'); + const url = attr(this, 'url') || '' , request = { ...this.requestProps, headers: this.requestHeaders } - , slice = { detail: { request }, target: this } - , updateSlice = slice => - { for( let parent = s.element.parentElement; parent; parent = parent.parentElement ) - if ( parent.onSlice ) - return parent.onSlice(slice); - console.error(`${this.localName} used outside of custom-element`) - debugger; - }; - + , slice = { request } + , update = () => this.dispatchEvent( new Event('change') ); + this.value = slice; setTimeout( async ()=> - { updateSlice( slice ); + { update(); const response = await fetch(url,{ ...this.requestProps, signal: controller.signal, headers: this.requestHeaders }) - , r= {headers: {}}; - [...response.headers].map( ([k,v]) => r.headers[k]=v ); - 'ok,status,statusText,type,url,redirected'.split(',').map(k=>r[k]=response[k]) + , r = {headers: {}}; + [...response.headers].map( ([k,v]) => r.headers[k] = v ); + 'ok,status,statusText,type,url,redirected'.split(',').map( k=> r[k] = response[k] ) - slice.detail.response = r; - updateSlice( slice ); - const detail = {...slice.detail} - detail.data = await response.json(); - const s = {...slice, detail} - updateSlice( s ); + slice.response = r; + update(); + slice.data = await response.json(); + update(); },0 ); - - return s; } } diff --git a/src/index.html b/src/index.html index 5149223..92635cf 100644 --- a/src/index.html +++ b/src/index.html @@ -25,11 +25,12 @@

custom-element demo

The data query is powered by XPath.

Try in Sandbox

- Data layer demo +

Data layer demo

local-storage | http-request | location-element | - external template + external template | + DOM merge on dynamic update
custom-element demo πŸ₯• @@ -108,13 +109,13 @@

custom-element demo

diff --git a/src/local-storage.js b/src/local-storage.js index a3b69d6..f9e6117 100644 --- a/src/local-storage.js +++ b/src/local-storage.js @@ -1,5 +1,4 @@ -const attr = (el, attr)=> el.getAttribute(attr) -, string2value = (type, v) => +const string2value = (type, v) => { if( type === 'text') return v; if( type === 'json') @@ -24,42 +23,36 @@ function ensureTrackLocalStorage() export class LocalStorageElement extends HTMLElement { - // @attribute live - monitors localStorage change - // @attribute type - `text|json`, defaults to text, other types are compatible with INPUT field - constructor() + static get observedAttributes() { + return [ 'value' // populated from localStorage, if defined initially, sets the valiue in storage + , 'slice' + , 'key' + , 'type' // `text|json`, defaults to text, other types are compatible with INPUT field + , 'live' // monitors localStorage change + ]; + } + + async connectedCallback() { - super(); - const state = {} - , type = attr(this, 'type') || 'text' - , listener = e=> e.detail.key === attr( this,'key' ) && propagateSlice() - , propagateSlice = ()=> - { for( let parent = this.parentElement; parent; parent = parent.parentElement) - if( parent.onSlice ) - return parent.onSlice( - { detail: string2value( type, localStorage.getItem( attr( this, 'key' ) ) ) - , target: this - } ); - console.error(`${this.localName} used outside of custom-element`) - debugger; - }; - this.sliceInit = s => - { if( !state.listener && this.hasAttribute('live') ) - { state.listener = 1; - window.addEventListener( 'local-storage', listener ); - ensureTrackLocalStorage(); - } - propagateSlice(); - return s || {} + const attr = attr => this.getAttribute(attr) + , fromStorage = ()=> + { this.value = string2value( attr('type'), localStorage.getItem( attr( 'key' ) ) ); + this.dispatchEvent( new Event('change') ) + } + // todo apply type + if( this.hasAttribute('value')) + localStorage.setItem( attr( this, 'key' ) ) + else + fromStorage() + + if( this.hasAttribute('live') ) + { const listener = (e => e.detail.key === attr( 'key' ) && fromStorage()); + window.addEventListener( 'local-storage', listener ); + ensureTrackLocalStorage(); + this._destroy = ()=> window.removeEventListener('local-storage', listener ); } - this._destroy = ()=> - { - if( !state.listener ) - return; - state.listener && window.removeEventListener('local-storage', listener ); - delete state.listener; - }; } - disconnectedCallback(){ this._destroy(); } + disconnectedCallback(){ this._destroy?.(); } } window.customElements.define( 'local-storage', LocalStorageElement ); diff --git a/src/location-element.js b/src/location-element.js index 11d0f0d..c236409 100644 --- a/src/location-element.js +++ b/src/location-element.js @@ -2,15 +2,20 @@ const attr = (el, attr)=> el.getAttribute(attr); export class LocationElement extends HTMLElement { - // @attribute live - monitors localStorage change - // @attribute src - URL to be parsed, defaults to `window.location` + static get observedAttributes() + { return [ 'value' // populated from localStorage, if defined initially, sets the valiue in storage + , 'slice' + , 'live' // monitors location change + , 'src' // URL to be parsed, defaults to `window.location` + ]; + } constructor() { super(); const state = {} , listener = e=> propagateSlice(e) - , propagateSlice = (e)=> + , propagateSlice = ()=> { const urlStr = attr(this,'src') const url = urlStr? new URL(urlStr) : window.location @@ -24,21 +29,14 @@ export class LocationElement extends HTMLElement { if ('string' === typeof url[k]) detail[k] = url[k] } - for( let parent = this.parentElement; parent; parent = parent.parentElement) - { if (parent.onSlice) - return parent.onSlice( - { detail - , target: this - }); - } - console.error(`${this.localName} used outside of custom-element`) - debugger; + this.value = detail; + this.dispatchEvent( new Event('change') ); }; this.sliceInit = s => { if( !state.listener && this.hasAttribute('live') ) { state.listener = 1; - window.addEventListener( 'popstate', listener ); + window.addEventListener( 'popstate' , listener ); window.addEventListener( 'hashchange', listener ); } propagateSlice(); @@ -49,14 +47,15 @@ export class LocationElement extends HTMLElement if( !state.listener ) return; if(state.listener) - { window.removeEventListener('popstate', listener); + { window.removeEventListener('popstate' , listener); window.removeEventListener('hashchange', listener); } delete state.listener; }; - this.sliceInit() + } - disconnectedCallback(){ this._destroy(); } + connectedCallback(){ this.sliceInit() } + disconnectedCallback(){ this._destroy() } } window.customElements.define( 'location-element', LocationElement ); diff --git a/test/location-element.test.js b/test/location-element.test.js index b53e04f..03e53cd 100644 --- a/test/location-element.test.js +++ b/test/location-element.test.js @@ -18,7 +18,7 @@ describe('location-element', () => { const el = await renderStory(LocationElementLoad); const match = prop => - expect(el.innerText).to.include(prop+``+window.location[prop]); + expect(el.innerText).to.include(prop+'\t'+window.location[prop]); match('protocol') match('host') match('hostname') @@ -31,7 +31,7 @@ describe('location-element', () => { const mySearchParams = new URLSearchParams(window.location.search); for (const [key, value] of mySearchParams) { - expect(el.innerText).to.include(key+``+value); + expect(el.innerText).to.include(key+`\t`+value); } }); @@ -40,7 +40,7 @@ describe('location-element', () => { const el = await renderStory(LocationElementLive); const match = prop => - expect(el.innerText).to.include(prop+``+window.location[prop]); + expect(el.innerText).to.include(prop+`\t`+window.location[prop]); match('protocol') match('host') match('hostname') @@ -53,7 +53,7 @@ describe('location-element', () => { const mySearchParams = new URLSearchParams(window.location.search); for (const [key, value] of mySearchParams) { - expect(el.innerText).to.include(key+``+value); + expect(el.innerText).to.include(key+`\t`+value); } window.location.hash = '#dce' await aTimeout(10); diff --git a/test/src-attribute.test.js b/test/src-attribute.test.js index 2d9384c..2305a3d 100644 --- a/test/src-attribute.test.js +++ b/test/src-attribute.test.js @@ -41,7 +41,7 @@ describe('src attribute', () => { sleep(100) expect(el.querySelectorAll('my-component').length).to.equal(0); - expect(el.querySelector('custom-element').firstElementChild.tagName.startsWith('DCE-')).to.equal(true); + expect(el.querySelector('custom-element').firstElementChild.localName.startsWith('dce-')).to.equal(true); expect(el.querySelector('custom-element').getAttribute('tag')).to.equal(''); expect(el.querySelectorAll('svg').length).to.equal(1); }); @@ -69,7 +69,7 @@ describe('src attribute', () => { it('src=html', async () => { const el = await renderStory(HtmlTemplate); - await sleep(100) + await sleep(1000) expect(el.innerHTML).to.include('