import MagicString from 'magic-string'; import { walk } from 'estree-walker'; import deindent from './utils/deindent.js'; import walkHtml from './utils/walkHtml.js'; import flattenReference from './utils/flattenReference.js'; import isReference from './utils/isReference.js'; import contextualise from './utils/contextualise.js'; import counter from './utils/counter.js'; import attributeLookup from './attributes/lookup.js'; function createRenderer ( fragment ) { return deindent` function ${fragment.name} ( component, target${fragment.useAnchor ? ', anchor' : ''} ) { ${fragment.initStatements.join( '\n\n' )} return { update: function ( ${fragment.contextChain.join( ', ' )} ) { ${fragment.updateStatements.join( '\n\n' )} }, teardown: function () { ${fragment.teardownStatements.join( '\n\n' )} } }; } `; } export default function generate ( parsed, template ) { const code = new MagicString( template ); function addSourcemapLocations ( node ) { walk( node, { enter ( node ) { code.addSourcemapLocation( node.start ); code.addSourcemapLocation( node.end ); } }); } const templateProperties = {}; if ( parsed.js ) { addSourcemapLocations( parsed.js.content ); const defaultExport = parsed.js.content.body.find( node => node.type === 'ExportDefaultDeclaration' ); if ( defaultExport ) { code.overwrite( defaultExport.start, defaultExport.declaration.start, `const template = ` ); defaultExport.declaration.properties.forEach( prop => { templateProperties[ prop.key.name ] = prop.value; }); } } const renderers = []; const expressionFunctions = []; const getName = counter(); // TODO use getName instead of counters const counters = { if: 0, each: 0 }; // TODO (scoped) css let current = { useAnchor: false, name: 'renderMainFragment', target: 'target', initStatements: [], updateStatements: [], teardownStatements: [], contexts: {}, contextChain: [ 'root' ], counter: counter(), parent: null }; parsed.html.children.forEach( child => { walkHtml( child, { Element: { enter ( node ) { const name = current.counter( node.name ); const initStatements = [ `var ${name} = document.createElement( '${node.name}' );` ]; const updateStatements = []; const teardownStatements = [ `${name}.parentNode.removeChild( ${name} );` ]; const allUsedContexts = new Set(); node.attributes.forEach( attribute => { if ( attribute.type === 'Attribute' ) { let metadata = attributeLookup[ attribute.name ]; if ( metadata.appliesTo && !~metadata.appliesTo.indexOf( node.name ) ) metadata = null; if ( attribute.value === true ) { // attributes without values, e.g. <textarea readonly> if ( metadata ) { initStatements.push( deindent` ${name}.${metadata.propertyName} = true; ` ); } else { initStatements.push( deindent` ${name}.setAttribute( '${attribute.name}', true ); ` ); } } else if ( attribute.value.length === 1 ) { const value = attribute.value[0]; let result = ''; if ( value.type === 'Text' ) { // static attributes result = JSON.stringify( value.data ); if ( metadata ) { initStatements.push( deindent` ${name}.${metadata.propertyName} = ${result}; ` ); } else { initStatements.push( deindent` ${name}.setAttribute( '${attribute.name}', ${result} ); ` ); } } else { // dynamic – but potentially non-string – attributes contextualise( code, value.expression, current.contexts ); result = `[✂${value.expression.start}-${value.expression.end}✂]`; if ( metadata ) { updateStatements.push( deindent` ${name}.${metadata.propertyName} = ${result}; ` ); } else { updateStatements.push( deindent` ${name}.setAttribute( '${attribute.name}', ${result} ); ` ); } } } else { const value = ( attribute.value[0].type === 'Text' ? '' : `"" + ` ) + ( attribute.value.map( chunk => { if ( chunk.type === 'Text' ) { return JSON.stringify( chunk.data ); } else { contextualise( code, chunk.expression, current.contexts ); return `[✂${chunk.expression.start}-${chunk.expression.end}✂]`; } }).join( ' + ' ) ); if ( metadata ) { updateStatements.push( deindent` ${name}.${metadata.propertyName} = ${value}; ` ); } else { updateStatements.push( deindent` ${name}.setAttribute( '${attribute.name}', ${value} ); ` ); } } } else if ( attribute.type === 'EventHandler' ) { // TODO verify that it's a valid callee (i.e. built-in or declared method) const handler = current.counter( `${attribute.name}Handler` ); addSourcemapLocations( attribute.expression ); code.insertRight( attribute.expression.start, 'component.' ); const usedContexts = new Set(); attribute.expression.arguments.forEach( arg => { const contexts = contextualise( code, arg, current.contexts ); contexts.forEach( context => { usedContexts.add( context ); allUsedContexts.add( context ); }); }); // TODO hoist event handlers? can do `this.__component.method(...)` if ( usedContexts.size ) { initStatements.push( deindent` function ${handler} ( event ) { var context = this.__context; ${[...usedContexts].map( name => `var ${name} = context.${name}` ).join( '\n' )} [✂${attribute.expression.start}-${attribute.expression.end}✂]; } ${name}.addEventListener( '${attribute.name}', ${handler}, false ); ` ); } else { initStatements.push( deindent` function ${handler} ( event ) { [✂${attribute.expression.start}-${attribute.expression.end}✂]; } ${name}.addEventListener( '${attribute.name}', ${handler}, false ); ` ); } teardownStatements.push( deindent` ${name}.removeEventListener( '${attribute.name}', ${handler}, false ); ` ); } else { throw new Error( `Not implemented: ${attribute.type}` ); } }); if ( allUsedContexts.size ) { initStatements.push( deindent` ${name}.__context = {}; ` ); updateStatements.push( deindent` ${[...allUsedContexts].map( contextName => `${name}.__context.${contextName} = ${contextName};` ).join( '\n' )} ` ); } current.initStatements.push( initStatements.join( '\n' ) ); if ( updateStatements.length ) current.updateStatements.push( updateStatements.join( '\n' ) ); current.teardownStatements.push( teardownStatements.join( '\n' ) ); current = Object.assign( {}, current, { target: name, parent: current }); }, leave () { const name = current.target; current = current.parent; if ( current.useAnchor && current.target === 'target' ) { current.initStatements.push( deindent` target.insertBefore( ${name}, anchor ); ` ); } else { current.initStatements.push( deindent` ${current.target}.appendChild( ${name} ); ` ); } } }, Text: { enter ( node ) { current.initStatements.push( deindent` ${current.target}.appendChild( document.createTextNode( ${JSON.stringify( node.data )} ) ); ` ); } }, MustacheTag: { enter ( node ) { const name = current.counter( 'text' ); current.initStatements.push( deindent` var ${name} = document.createTextNode( '' ); var ${name}_value = ''; ${current.target}.appendChild( ${name} ); ` ); const usedContexts = contextualise( code, node.expression, current.contexts ); const snippet = `[✂${node.expression.start}-${node.expression.end}✂]`; if ( isReference( node.expression ) ) { const reference = `${template.slice( node.expression.start, node.expression.end )}`; const qualified = usedContexts[0] === 'root' ? `root.${reference}` : reference; current.updateStatements.push( deindent` if ( ${snippet} !== ${name}_value ) { ${name}_value = ${qualified}; ${name}.data = ${name}_value; } ` ); } else { const expressionFunction = getName( 'expression' ); expressionFunctions.push( deindent` function ${expressionFunction} ( ${usedContexts.join( ', ' )} ) { return ${snippet}; } ` ); const temp = getName( 'temp' ); current.updateStatements.push( deindent` var ${temp} = ${expressionFunction}( ${usedContexts.join( ', ' )} ); if ( ${temp} !== ${name}_value ) { ${name}_value = ${temp}; ${name}.data = ${name}_value; } ` ); } } }, IfBlock: { enter ( node ) { const i = counters.if++; const name = `ifBlock_${i}`; const renderer = `renderIfBlock_${i}`; current.initStatements.push( deindent` var ${name}_anchor = document.createComment( ${JSON.stringify( `#if ${template.slice( node.expression.start, node.expression.end )}` )} ); ${current.target}.appendChild( ${name}_anchor ); var ${name} = null; ` ); const usedContexts = contextualise( code, node.expression, current.contexts ); const snippet = `[✂${node.expression.start}-${node.expression.end}✂]`; let expression; if ( isReference( node.expression ) ) { const reference = `${template.slice( node.expression.start, node.expression.end )}`; expression = usedContexts[0] === 'root' ? `root.${reference}` : reference; current.updateStatements.push( deindent` if ( ${snippet} && !${name} ) { ${name} = ${renderer}( component, ${current.target}, ${name}_anchor ); } ` ); } else { const expressionFunction = getName( 'expression' ); expressionFunctions.push( deindent` function ${expressionFunction} ( ${usedContexts.join( ', ' )} ) { return ${snippet}; } ` ); expression = `${name}_value`; current.updateStatements.push( deindent` var ${expression} = ${expressionFunction}( ${usedContexts.join( ', ' )} ); if ( ${expression} && !${name} ) { ${name} = ${renderer}( component, ${current.target}, ${name}_anchor ); } ` ); } current.updateStatements.push( deindent` else if ( !${expression} && ${name} ) { ${name}.teardown(); ${name} = null; } if ( ${name} ) { ${name}.update( ${current.contextChain.join( '\n' )} ); } ` ); current.teardownStatements.push( deindent` if ( ${name} ) ${name}.teardown(); ${name}_anchor.parentNode.removeChild( ${name}_anchor ); ` ); current = { useAnchor: true, name: renderer, target: 'target', contexts: current.contexts, contextChain: current.contextChain, initStatements: [], updateStatements: [], teardownStatements: [], counter: counter(), parent: current }; }, leave () { renderers.push( createRenderer( current ) ); current = current.parent; } }, EachBlock: { enter ( node ) { const i = counters.each++; const name = `eachBlock_${i}`; const renderer = `renderEachBlock_${i}`; current.initStatements.push( deindent` var ${name}_anchor = document.createComment( ${JSON.stringify( `#each ${template.slice( node.expression.start, node.expression.end )}` )} ); ${current.target}.appendChild( ${name}_anchor ); var ${name}_iterations = []; const ${name}_fragment = document.createDocumentFragment(); ` ); const usedContexts = contextualise( code, node.expression, current.contexts ); const snippet = `[✂${node.expression.start}-${node.expression.end}✂]`; let expression; if ( isReference( node.expression ) ) { expression = snippet; } else { const expressionFunction = getName( 'expression' ); expressionFunctions.push( deindent` function ${expressionFunction} ( ${usedContexts.join( ', ' )} ) { return ${snippet}; } ` ); expression = `${expressionFunction}( ${usedContexts.join( ', ' )} )`; } current.updateStatements.push( deindent` var ${name}_value = ${expression}; for ( var i = 0; i < ${name}_value.length; i += 1 ) { if ( !${name}_iterations[i] ) { ${name}_iterations[i] = ${renderer}( component, ${name}_fragment ); } const iteration = ${name}_iterations[i]; ${name}_iterations[i].update( ${current.contextChain.join( ', ' )}, ${name}_value[i]${node.index ? `, i` : ''} ); } for ( var i = ${name}_value.length; i < ${name}_iterations.length; i += 1 ) { ${name}_iterations[i].teardown(); } ${name}_anchor.parentNode.insertBefore( ${name}_fragment, ${name}_anchor ); ${name}_iterations.length = ${name}_value.length; ` ); current.teardownStatements.push( deindent` for ( let i = 0; i < ${name}_iterations.length; i += 1 ) { ${name}_iterations[i].teardown(); } ${name}_anchor.parentNode.removeChild( ${name}_anchor ); ` ); const contexts = Object.assign( {}, current.contexts ); const contextChain = current.contextChain.concat( node.context ); contexts[ node.context ] = true; if ( node.index ) { // not strictly a context, but we can treat it as such contextChain.push( node.index ); contexts[ node.index ] = true; } current = { useAnchor: false, name: renderer, target: 'target', contexts, contextChain, initStatements: [], updateStatements: [], teardownStatements: [], counter: counter(), parent: current }; }, leave () { renderers.push( createRenderer( current ) ); current = current.parent; } } }); }); renderers.push( createRenderer( current ) ); const setStatements = [ deindent` const oldState = state; state = Object.assign( {}, oldState, newState ); ` ]; if ( templateProperties.computed ) { const dependencies = new Map(); templateProperties.computed.properties.forEach( prop => { const key = prop.key.name; const value = prop.value; const deps = value.params.map( param => param.name ); dependencies.set( key, deps ); }); const visited = new Set(); function visit ( key ) { if ( !dependencies.has( key ) ) return; // not a computation if ( visited.has( key ) ) return; visited.add( key ); const deps = dependencies.get( key ); deps.forEach( visit ); setStatements.push( deindent` if ( ${deps.map( dep => `( '${dep}' in newState && typeof state.${dep} === 'object' || state.${dep} !== oldState.${dep} )` ).join( ' || ' )} ) { state.${key} = newState.${key} = template.computed.${key}( ${deps.map( dep => `state.${dep}` ).join( ', ' )} ); } ` ); } templateProperties.computed.properties.forEach( prop => visit( prop.key.name ) ); } setStatements.push( deindent` dispatchObservers( observers.immediate, newState, oldState ); mainFragment.update( state ); dispatchObservers( observers.deferred, newState, oldState ); ` ); const result = deindent` ${parsed.js ? `[✂${parsed.js.content.start}-${parsed.js.content.end}✂]` : ``} ${expressionFunctions.join( '\n\n' )} ${renderers.reverse().join( '\n\n' )} export default function createComponent ( options ) { var component = ${templateProperties.methods ? `Object.create( template.methods )` : `{}`}; var state = {}; var observers = { immediate: Object.create( null ), deferred: Object.create( null ) }; function dispatchObservers ( group, newState, oldState ) { for ( const key in group ) { if ( !( key in newState ) ) continue; const newValue = newState[ key ]; const oldValue = oldState[ key ]; if ( newValue === oldValue && typeof newValue !== 'object' ) continue; const callbacks = group[ key ]; if ( !callbacks ) continue; for ( let i = 0; i < callbacks.length; i += 1 ) { callbacks[i].call( component, newValue, oldValue ); } } } component.get = function get ( key ) { return state[ key ]; }; component.set = function set ( newState ) { ${setStatements.join( '\n\n' )} }; component.observe = function ( key, callback, options = {} ) { const group = options.defer ? observers.deferred : observers.immediate; ( group[ key ] || ( group[ key ] = [] ) ).push( callback ); if ( options.init !== false ) callback( state[ key ] ); return { cancel () { const index = group[ key ].indexOf( callback ); if ( ~index ) group[ key ].splice( index, 1 ); } }; }; component.teardown = function teardown () { mainFragment.teardown(); mainFragment = null; state = {}; }; let mainFragment = renderMainFragment( component, options.target ); component.set( ${templateProperties.data ? `Object.assign( template.data(), options.data )` : `options.data`} ); return component; } `; const pattern = /\[✂(\d+)-(\d+)$/; const parts = result.split( '✂]' ); const finalChunk = parts.pop(); const sortedByResult = parts.map( ( str, index ) => { const match = pattern.exec( str ); return { index, chunk: str.replace( pattern, '' ), start: +match[1], end: +match[2] }; }); const sortedBySource = sortedByResult .slice() .sort( ( a, b ) => a.start - b.start ); let c = 0; sortedBySource.forEach( part => { code.remove( c, part.start ); code.insertRight( part.start, part.chunk ); c = part.end; }); code.remove( c, template.length ); code.append( finalChunk ); sortedByResult.forEach( part => { code.move( part.start, part.end, 0 ); }); return { code: code.toString() }; }