svelte/compiler/generate/index.js

658 lines
18 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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()
};
}