import { parseExpressionAt } from 'acorn'; 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 Stylesheet from '../css/Stylesheet'; import { test } from '../config'; import Fragment from './nodes/Fragment'; import shared from './shared'; import { DomTarget } from './dom/index'; import { SsrTarget } from './ssr/index'; import { Node, GenerateOptions, ShorthandImport, Ast, 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 Compiler { stats: Stats; ast: Ast; source: string; name: string; options: CompileOptions; fragment: Fragment; target: DomTarget | SsrTarget; customElement: CustomElementOptions; tag: string; props: string[]; defaultExport: Node[]; imports: Node[]; shorthandImports: ShorthandImport[]; helpers: Set<string>; components: Set<string>; events: Set<string>; methods: Set<string>; transitions: Set<string>; actions: Set<string>; importedComponents: Map<string, string>; namespace: string; hasComponents: boolean; computations: Computation[]; templateProperties: Record<string, Node>; slots: Set<string>; javascript: string; code: MagicString; bindingGroups: string[]; indirectDependencies: Map<string, Set<string>>; expectedProperties: Set<string>; usesRefs: boolean; locate: (c: number) => { line: number, column: number }; stylesheet: Stylesheet; userVars: Set<string>; templateVars: Map<string, string>; aliases: Map<string, string>; usedNames: Set<string>; constructor( ast: Ast, source: string, name: string, stylesheet: Stylesheet, options: CompileOptions, stats: Stats, dom: boolean, target: DomTarget | SsrTarget ) { stats.start('compile'); this.stats = stats; this.ast = ast; this.source = source; this.options = options; this.target = target; 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, ast.html); // this.walkTemplate(); if (!this.customElement) this.stylesheet.reify(); stylesheet.warnOnUnusedSelectors(options.onwarn); } 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); } generate(result: string, options: CompileOptions, { banner = '', name, format }: GenerateOptions ) { const pattern = /\[✂(\d+)-(\d+)$/; const helpers = new Set(); // TODO use same regex for both result = result.replace(options.generate === 'ssr' ? /(@+|#+|%+)(\w*(?:-\w*)?)/g : /(%+|@+)(\w*(?:-\w*)?)/g, (match: string, sigil: string, name: string) => { if (sigil === '@') { if (name in shared) { if (options.dev && `${name}Dev` in shared) name = `${name}Dev`; helpers.add(name); } return this.alias(name); } if (sigil === '%') { return this.templateVars.get(name); } return sigil.slice(1) + name; }); let importedHelpers; if (options.shared) { if (format !== 'es' && format !== 'cjs') { throw new Error(`Components with shared helpers must be compiled with \`format: 'es'\` or \`format: 'cjs'\``); } importedHelpers = Array.from(helpers).sort().map(name => { const alias = this.alias(name); return { name, alias }; }); } else { let inlineHelpers = ''; const compiler = this; importedHelpers = []; helpers.forEach(name => { const str = shared[name]; const code = new MagicString(str); const expression = parseExpressionAt(str, 0); let { scope } = annotateWithScopes(expression); walk(expression, { enter(node: Node, parent: Node) { if (node._scope) scope = node._scope; if ( node.type === 'Identifier' && isReference(node, parent) && !scope.has(node.name) ) { if (node.name in shared) { // this helper function depends on another one const dependency = node.name; helpers.add(dependency); const alias = compiler.alias(dependency); if (alias !== node.name) { code.overwrite(node.start, node.end, alias); } } } }, leave(node: Node) { if (node._scope) scope = scope.parent; }, }); if (name === 'transitionManager') { // special case const global = `_svelteTransitionManager`; inlineHelpers += `\n\nvar ${this.alias('transitionManager')} = window.${global} || (window.${global} = ${code});\n\n`; } else if (name === 'escaped' || name === 'missingComponent') { // vars are an awkward special case... would be nice to avoid this const alias = this.alias(name); inlineHelpers += `\n\nconst ${alias} = ${code};` } else { const alias = this.alias(expression.id.name); if (alias !== expression.id.name) { code.overwrite(expression.id.start, expression.id.end, alias); } inlineHelpers += `\n\n${code}`; } }); result += inlineHelpers; } const sharedPath = options.shared === true ? 'svelte/shared.js' : options.shared || ''; const module = wrapModule(result, format, name, options, banner, sharedPath, importedHelpers, 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() { const localUsedNames = new Set(); function add(name: string) { localUsedNames.add(name); } reservedNames.forEach(add); this.userVars.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.ast; 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<string, boolean>) => { 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(); const fullStateComputations = []; templateProperties.computed.value.properties.forEach((prop: Node) => { const key = getName(prop.key); const value = prop.value; addDeclaration(key, value, false, 'computed', { state: true, changed: true }); const param = value.params[0]; if (param.type === 'ObjectPattern') { const deps = param.properties.map(prop => prop.key.name); deps.forEach(dep => { this.expectedProperties.add(dep); }); dependencies.set(key, deps); } else { fullStateComputations.push({ key, deps: null }) } }); 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); }; templateProperties.computed.value.properties.forEach((prop: Node) => visit(getName(prop.key)) ); if (fullStateComputations.length > 0) { computations.push(...fullStateComputations); } } 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}✂]`; } } } }