import MagicString, { Bundle } from 'magic-string'; import isReference from 'is-reference'; import { walk, childKeys } from 'estree-walker'; import { getLocator } from 'locate-character'; import Stats from '../Stats'; import deindent from '../utils/deindent'; import CodeBuilder from '../utils/CodeBuilder'; import flattenReference from '../utils/flattenReference'; import reservedNames from '../utils/reservedNames'; import namespaces from '../utils/namespaces'; import { removeNode, removeObjectKey } from '../utils/removeNode'; import nodeToString from '../utils/nodeToString'; import wrapModule from './wrapModule'; import annotateWithScopes, { Scope } from '../utils/annotateWithScopes'; import getName from '../utils/getName'; import clone from '../utils/clone'; import Stylesheet from '../css/Stylesheet'; import { test } from '../config'; import nodes from './nodes/index'; import Fragment from './nodes/Fragment'; import { Node, GenerateOptions, ShorthandImport, Parsed, CompileOptions, CustomElementOptions } from '../interfaces'; interface Computation { key: string; deps: string[] } function detectIndentation(str: string) { const pattern = /^[\t\s]{1,4}/gm; let match; while (match = pattern.exec(str)) { if (match[0][0] === '\t') return '\t'; if (match[0].length === 2) return ' '; } return ' '; } function getIndentationLevel(str: string, b: number) { let a = b; while (a > 0 && str[a - 1] !== '\n') a -= 1; return /^\s*/.exec(str.slice(a, b))[0]; } function getIndentExclusionRanges(node: Node) { const ranges: Node[] = []; walk(node, { enter(node: Node) { if (node.type === 'TemplateElement') ranges.push(node); } }); return ranges; } function removeIndentation( code: MagicString, start: number, end: number, indentationLevel: string, ranges: Node[] ) { const str = code.original.slice(start, end); const pattern = new RegExp(`^${indentationLevel}`, 'gm'); let match; while (match = pattern.exec(str)) { // TODO bail if we're inside an exclusion range code.remove(start + match.index, start + match.index + indentationLevel.length); } } // We need to tell estree-walker that it should always // look for an `else` block, otherwise it might get // the wrong idea about the shape of each/if blocks childKeys.EachBlock = childKeys.IfBlock = ['children', 'else']; childKeys.Attribute = ['value']; export default class Generator { stats: Stats; ast: Parsed; parsed: Parsed; source: string; name: string; options: CompileOptions; fragment: Fragment; customElement: CustomElementOptions; tag: string; props: string[]; defaultExport: Node[]; imports: Node[]; shorthandImports: ShorthandImport[]; helpers: Set; components: Set; events: Set; methods: Set; transitions: Set; actions: Set; importedComponents: Map; namespace: string; hasComponents: boolean; computations: Computation[]; templateProperties: Record; slots: Set; javascript: string; code: MagicString; bindingGroups: string[]; indirectDependencies: Map>; expectedProperties: Set; usesRefs: boolean; locate: (c: number) => { line: number, column: number }; stylesheet: Stylesheet; userVars: Set; templateVars: Map; aliases: Map; usedNames: Set; constructor( parsed: Parsed, source: string, name: string, stylesheet: Stylesheet, options: CompileOptions, stats: Stats, dom: boolean ) { stats.start('compile'); this.stats = stats; this.ast = clone(parsed); this.parsed = parsed; this.source = source; this.options = options; this.imports = []; this.shorthandImports = []; this.helpers = new Set(); this.components = new Set(); this.events = new Set(); this.methods = new Set(); this.transitions = new Set(); this.actions = new Set(); this.importedComponents = new Map(); this.slots = new Set(); this.bindingGroups = []; this.indirectDependencies = new Map(); this.locate = getLocator(this.source); // track which properties are needed, so we can provide useful info // in dev mode this.expectedProperties = new Set(); this.code = new MagicString(source); this.usesRefs = false; // styles this.stylesheet = stylesheet; // allow compiler to deconflict user's `import { get } from 'whatever'` and // Svelte's builtin `import { get, ... } from 'svelte/shared.ts'`; this.userVars = new Set(); this.templateVars = new Map(); this.aliases = new Map(); this.usedNames = new Set(); this.computations = []; this.templateProperties = {}; this.walkJs(dom); this.name = this.alias(name); if (options.customElement === true) { this.customElement = { tag: this.tag, props: this.props } } else { this.customElement = options.customElement; } if (this.customElement && !this.customElement.tag) { throw new Error(`No tag name specified`); // TODO better error } this.fragment = new Fragment(this, parsed.html); // this.walkTemplate(); if (!this.customElement) this.stylesheet.reify(); } addSourcemapLocations(node: Node) { walk(node, { enter: (node: Node) => { this.code.addSourcemapLocation(node.start); this.code.addSourcemapLocation(node.end); }, }); } alias(name: string) { if (!this.aliases.has(name)) { this.aliases.set(name, this.getUniqueName(name)); } return this.aliases.get(name); } // contextualise( // contexts: Map, // indexes: Map, // expression: Node, // context: string, // isEventHandler: boolean // ): { // contexts: Set, // indexes: Set // } { // // this.addSourcemapLocations(expression); // const usedContexts: Set = new Set(); // const usedIndexes: Set = new Set(); // const { code, helpers } = this; // let scope: Scope; // let lexicalDepth = 0; // const self = this; // walk(expression, { // enter(node: Node, parent: Node, key: string) { // if (/^Function/.test(node.type)) lexicalDepth += 1; // if (node._scope) { // scope = node._scope; // return; // } // if (node.type === 'ThisExpression') { // if (lexicalDepth === 0 && context) // code.overwrite(node.start, node.end, context, { // storeName: true, // contentOnly: false, // }); // } else if (isReference(node, parent)) { // const { name } = flattenReference(node); // if (scope && scope.has(name)) return; // if (name === 'event' && isEventHandler) { // // noop // } else if (contexts.has(name)) { // const contextName = contexts.get(name); // if (contextName !== name) { // // this is true for 'reserved' names like `state` and `component`, // // also destructured contexts // code.overwrite( // node.start, // node.start + name.length, // contextName, // { storeName: true, contentOnly: false } // ); // const destructuredName = contextName.replace(/\[\d+\]/, ''); // if (destructuredName !== contextName) { // // so that hoisting the context works correctly // usedContexts.add(destructuredName); // } // } // usedContexts.add(name); // } else if (helpers.has(name)) { // let object = node; // while (object.type === 'MemberExpression') object = object.object; // const alias = self.templateVars.get(`helpers-${name}`); // if (alias !== name) code.overwrite(object.start, object.end, alias); // } else if (indexes.has(name)) { // const context = indexes.get(name); // usedContexts.add(context); // TODO is this right? // usedIndexes.add(name); // } else { // // handle shorthand properties // if (parent && parent.type === 'Property' && parent.shorthand) { // if (key === 'key') { // code.appendLeft(node.start, `${name}: `); // return; // } // } // code.prependRight(node.start, `state.`); // usedContexts.add('state'); // } // this.skip(); // } // }, // leave(node: Node) { // if (/^Function/.test(node.type)) lexicalDepth -= 1; // if (node._scope) scope = scope.parent; // }, // }); // return { // contexts: usedContexts, // indexes: usedIndexes // }; // } generate(result: string, options: CompileOptions, { banner = '', sharedPath, helpers, name, format }: GenerateOptions ) { const pattern = /\[✂(\d+)-(\d+)$/; const module = wrapModule(result, format, name, options, banner, sharedPath, helpers, this.imports, this.shorthandImports, this.source); const parts = module.split('✂]'); const finalChunk = parts.pop(); const compiled = new Bundle({ separator: '' }); function addString(str: string) { compiled.addSource({ content: new MagicString(str), }); } const { filename } = options; // special case — the source file doesn't actually get used anywhere. we need // to add an empty file to populate map.sources and map.sourcesContent if (!parts.length) { compiled.addSource({ filename, content: new MagicString(this.source).remove(0, this.source.length), }); } parts.forEach((str: string) => { const chunk = str.replace(pattern, ''); if (chunk) addString(chunk); const match = pattern.exec(str); const snippet = this.code.snip(+match[1], +match[2]); compiled.addSource({ filename, content: snippet, }); }); addString(finalChunk); const css = this.customElement ? { code: null, map: null } : this.stylesheet.render(options.cssOutputFilename, true); const js = { code: compiled.toString(), map: compiled.generateMap({ includeContent: true, file: options.outputFilename, }) }; this.stats.stop('compile'); return { ast: this.ast, js, css, stats: this.stats.render(this) }; } getUniqueName(name: string) { if (test) name = `${name}$`; let alias = name; for ( let i = 1; reservedNames.has(alias) || this.userVars.has(alias) || this.usedNames.has(alias); alias = `${name}_${i++}` ); this.usedNames.add(alias); return alias; } getUniqueNameMaker(names: string[]) { const localUsedNames = new Set(); function add(name: string) { localUsedNames.add(name); } reservedNames.forEach(add); this.userVars.forEach(add); names.forEach(add); return (name: string) => { if (test) name = `${name}$`; let alias = name; for ( let i = 1; this.usedNames.has(alias) || localUsedNames.has(alias); alias = `${name}_${i++}` ); localUsedNames.add(alias); return alias; }; } walkJs(dom: boolean) { const { code, source, computations, methods, templateProperties, imports } = this; const { js } = this.parsed; const componentDefinition = new CodeBuilder(); if (js) { this.addSourcemapLocations(js.content); const indentation = detectIndentation(source.slice(js.start, js.end)); const indentationLevel = getIndentationLevel(source, js.content.body[0].start); const indentExclusionRanges = getIndentExclusionRanges(js.content); const { scope, globals } = annotateWithScopes(js.content); scope.declarations.forEach(name => { this.userVars.add(name); }); globals.forEach(name => { this.userVars.add(name); }); const body = js.content.body.slice(); // slice, because we're going to be mutating the original // imports need to be hoisted out of the IIFE for (let i = 0; i < body.length; i += 1) { const node = body[i]; if (node.type === 'ImportDeclaration') { removeNode(code, js.content, node); imports.push(node); node.specifiers.forEach((specifier: Node) => { this.userVars.add(specifier.local.name); }); } } const defaultExport = this.defaultExport = body.find( (node: Node) => node.type === 'ExportDefaultDeclaration' ); if (defaultExport) { defaultExport.declaration.properties.forEach((prop: Node) => { templateProperties[getName(prop.key)] = prop; }); ['helpers', 'events', 'components', 'transitions', 'actions'].forEach(key => { if (templateProperties[key]) { templateProperties[key].value.properties.forEach((prop: Node) => { this[key].add(getName(prop.key)); }); } }); const addArrowFunctionExpression = (name: string, node: Node) => { const { body, params, async } = node; const fnKeyword = async ? 'async function' : 'function'; const paramString = params.length ? `[✂${params[0].start}-${params[params.length - 1].end}✂]` : ``; if (body.type === 'BlockStatement') { componentDefinition.addBlock(deindent` ${fnKeyword} ${name}(${paramString}) [✂${body.start}-${body.end}✂] `); } else { componentDefinition.addBlock(deindent` ${fnKeyword} ${name}(${paramString}) { return [✂${body.start}-${body.end}✂]; } `); } }; const addFunctionExpression = (name: string, node: Node) => { const { async } = node; const fnKeyword = async ? 'async function' : 'function'; let c = node.start; while (this.source[c] !== '(') c += 1; componentDefinition.addBlock(deindent` ${fnKeyword} ${name}[✂${c}-${node.end}✂]; `); }; const addValue = (name: string, node: Node) => { componentDefinition.addBlock(deindent` var ${name} = [✂${node.start}-${node.end}✂]; `); }; const addDeclaration = (key: string, node: Node, allowShorthandImport?: boolean, disambiguator?: string, conflicts?: Record) => { const qualified = disambiguator ? `${disambiguator}-${key}` : key; if (node.type === 'Identifier' && node.name === key) { this.templateVars.set(qualified, key); return; } let deconflicted = key; if (conflicts) while (deconflicted in conflicts) deconflicted += '_' let name = this.getUniqueName(deconflicted); this.templateVars.set(qualified, name); if (allowShorthandImport && node.type === 'Literal' && typeof node.value === 'string') { this.shorthandImports.push({ name, source: node.value }); return; } // deindent const indentationLevel = getIndentationLevel(source, node.start); if (indentationLevel) { removeIndentation(code, node.start, node.end, indentationLevel, indentExclusionRanges); } if (node.type === 'ArrowFunctionExpression') { addArrowFunctionExpression(name, node); } else if (node.type === 'FunctionExpression') { addFunctionExpression(name, node); } else { addValue(name, node); } }; if (templateProperties.components) { templateProperties.components.value.properties.forEach((property: Node) => { addDeclaration(getName(property.key), property.value, true, 'components'); }); } if (templateProperties.computed) { const dependencies = new Map(); templateProperties.computed.value.properties.forEach((prop: Node) => { const key = getName(prop.key); const value = prop.value; const deps = value.params[0].properties.map(prop => prop.key.name); deps.forEach(dep => { this.expectedProperties.add(dep); }); dependencies.set(key, deps); }); const visited = new Set(); const visit = (key: string) => { if (!dependencies.has(key)) return; // not a computation if (visited.has(key)) return; visited.add(key); const deps = dependencies.get(key); deps.forEach(visit); computations.push({ key, deps }); const prop = templateProperties.computed.value.properties.find((prop: Node) => getName(prop.key) === key); addDeclaration(key, prop.value, false, 'computed', { state: true, changed: true }); }; templateProperties.computed.value.properties.forEach((prop: Node) => visit(getName(prop.key)) ); } if (templateProperties.data) { addDeclaration('data', templateProperties.data.value); } if (templateProperties.events && dom) { templateProperties.events.value.properties.forEach((property: Node) => { addDeclaration(getName(property.key), property.value, false, 'events'); }); } if (templateProperties.helpers) { templateProperties.helpers.value.properties.forEach((property: Node) => { addDeclaration(getName(property.key), property.value, false, 'helpers'); }); } if (templateProperties.methods && dom) { addDeclaration('methods', templateProperties.methods.value); templateProperties.methods.value.properties.forEach(prop => { this.methods.add(prop.key.name); }); } if (templateProperties.namespace) { const ns = nodeToString(templateProperties.namespace.value); this.namespace = namespaces[ns] || ns; } if (templateProperties.oncreate && dom) { addDeclaration('oncreate', templateProperties.oncreate.value); } if (templateProperties.ondestroy && dom) { addDeclaration('ondestroy', templateProperties.ondestroy.value); } if (templateProperties.onstate && dom) { addDeclaration('onstate', templateProperties.onstate.value); } if (templateProperties.onupdate && dom) { addDeclaration('onupdate', templateProperties.onupdate.value); } if (templateProperties.preload) { addDeclaration('preload', templateProperties.preload.value); } if (templateProperties.props) { this.props = templateProperties.props.value.elements.map((element: Node) => nodeToString(element)); } if (templateProperties.setup) { addDeclaration('setup', templateProperties.setup.value); } if (templateProperties.store) { addDeclaration('store', templateProperties.store.value); } if (templateProperties.tag) { this.tag = nodeToString(templateProperties.tag.value); } if (templateProperties.transitions) { templateProperties.transitions.value.properties.forEach((property: Node) => { addDeclaration(getName(property.key), property.value, false, 'transitions'); }); } if (templateProperties.actions) { templateProperties.actions.value.properties.forEach((property: Node) => { addDeclaration(getName(property.key), property.value, false, 'actions'); }); } } if (indentationLevel) { if (defaultExport) { removeIndentation(code, js.content.start, defaultExport.start, indentationLevel, indentExclusionRanges); removeIndentation(code, defaultExport.end, js.content.end, indentationLevel, indentExclusionRanges); } else { removeIndentation(code, js.content.start, js.content.end, indentationLevel, indentExclusionRanges); } } let a = js.content.start; while (/\s/.test(source[a])) a += 1; let b = js.content.end; while (/\s/.test(source[b - 1])) b -= 1; if (defaultExport) { this.javascript = ''; if (a !== defaultExport.start) this.javascript += `[✂${a}-${defaultExport.start}✂]`; if (!componentDefinition.isEmpty()) this.javascript += componentDefinition; if (defaultExport.end !== b) this.javascript += `[✂${defaultExport.end}-${b}✂]`; } else { this.javascript = a === b ? null : `[✂${a}-${b}✂]`; } } } // walkTemplate() { // const generator = this; // const { // code, // expectedProperties, // helpers // } = this; // const contextualise = ( // node: Node, contextDependencies: Map, // indexes: Set, // isEventHandler: boolean // ) => { // this.addSourcemapLocations(node); // TODO this involves an additional walk — can we roll it in somewhere else? // let { scope } = annotateWithScopes(node); // const dependencies: Set = new Set(); // walk(node, { // enter(node: Node, parent: Node) { // code.addSourcemapLocation(node.start); // code.addSourcemapLocation(node.end); // if (node._scope) { // scope = node._scope; // return; // } // if (isReference(node, parent)) { // const { name } = flattenReference(node); // if (scope && scope.has(name) || helpers.has(name) || (name === 'event' && isEventHandler)) return; // if (contextDependencies.has(name)) { // contextDependencies.get(name).forEach(dependency => { // dependencies.add(dependency); // }); // } else if (!indexes.has(name)) { // dependencies.add(name); // } // this.skip(); // } // }, // leave(node: Node, parent: Node) { // if (node._scope) scope = scope.parent; // } // }); // dependencies.forEach(dependency => { // expectedProperties.add(dependency); // }); // return { // snippet: `[✂${node.start}-${node.end}✂]`, // dependencies: Array.from(dependencies) // }; // } // const contextStack = []; // const indexStack = []; // const dependenciesStack = []; // let contextDependencies = new Map(); // const contextDependenciesStack: Map[] = [contextDependencies]; // let indexes = new Set(); // const indexesStack: Set[] = [indexes]; // function parentIsHead(node) { // if (!node) return false; // if (node.type === 'Component' || node.type === 'Element') return false; // if (node.type === 'Head') return true; // return parentIsHead(node.parent); // } // walk(this.fragment, { // enter(node: Node, parent: Node, key: string) { // // TODO this is hacky as hell // if (key === 'parent') return this.skip(); // node.parent = parent; // node.generator = generator; // if (node.type === 'Element' && (node.name === 'svelte:component' || node.name === 'svelte:self' || generator.components.has(node.name))) { // node.type = 'Component'; // Object.setPrototypeOf(node, nodes.Component.prototype); // } else if (node.type === 'Element' && node.name === 'title' && parentIsHead(parent)) { // TODO do this in parse? // node.type = 'Title'; // Object.setPrototypeOf(node, nodes.Title.prototype); // } else if (node.type === 'Element' && node.name === 'slot' && !generator.customElement) { // node.type = 'Slot'; // Object.setPrototypeOf(node, nodes.Slot.prototype); // } else if (node.type in nodes) { // Object.setPrototypeOf(node, nodes[node.type].prototype); // } // if (node.type === 'Element') { // generator.stylesheet.apply(node); // } // if (node.type === 'EachBlock') { // node.metadata = contextualise(node.expression, contextDependencies, indexes, false); // contextDependencies = new Map(contextDependencies); // contextDependencies.set(node.context, node.metadata.dependencies); // if (node.destructuredContexts) { // node.destructuredContexts.forEach((name: string) => { // contextDependencies.set(name, node.metadata.dependencies); // }); // } // contextDependenciesStack.push(contextDependencies); // if (node.index) { // indexes = new Set(indexes); // indexes.add(node.index); // indexesStack.push(indexes); // } // } // if (node.type === 'AwaitBlock') { // node.metadata = contextualise(node.expression, contextDependencies, indexes, false); // contextDependencies = new Map(contextDependencies); // contextDependencies.set(node.value, node.metadata.dependencies); // contextDependencies.set(node.error, node.metadata.dependencies); // contextDependenciesStack.push(contextDependencies); // } // if (node.type === 'IfBlock') { // node.metadata = contextualise(node.expression, contextDependencies, indexes, false); // } // if (node.type === 'MustacheTag' || node.type === 'RawMustacheTag' || node.type === 'AttributeShorthand') { // node.metadata = contextualise(node.expression, contextDependencies, indexes, false); // this.skip(); // } // if (node.type === 'Binding') { // node.metadata = contextualise(node.value, contextDependencies, indexes, false); // this.skip(); // } // if (node.type === 'EventHandler' && node.expression) { // node.expression.arguments.forEach((arg: Node) => { // arg.metadata = contextualise(arg, contextDependencies, indexes, true); // }); // this.skip(); // } // if (node.type === 'Transition' && node.expression) { // node.metadata = contextualise(node.expression, contextDependencies, indexes, false); // this.skip(); // } // if (node.type === 'Action' && node.expression) { // node.metadata = contextualise(node.expression, contextDependencies, indexes, false); // if (node.expression.type === 'CallExpression') { // node.expression.arguments.forEach((arg: Node) => { // arg.metadata = contextualise(arg, contextDependencies, indexes, true); // }); // } // this.skip(); // } // if (node.type === 'Component' && node.name === 'svelte:component') { // node.metadata = contextualise(node.expression, contextDependencies, indexes, false); // } // if (node.type === 'Spread') { // node.metadata = contextualise(node.expression, contextDependencies, indexes, false); // } // }, // leave(node: Node, parent: Node) { // if (node.type === 'EachBlock') { // contextDependenciesStack.pop(); // contextDependencies = contextDependenciesStack[contextDependenciesStack.length - 1]; // if (node.index) { // indexesStack.pop(); // indexes = indexesStack[indexesStack.length - 1]; // } // } // if (node.type === 'Element' && node.name === 'option') { // // Special case — treat these the same way: // // // // // const valueAttribute = node.attributes.find((attribute: Node) => attribute.name === 'value'); // if (!valueAttribute) { // node.attributes.push(new nodes.Attribute({ // generator, // name: 'value', // value: node.children, // parent: node // })); // } // } // } // }); // } }