import MagicString, { Bundle } from 'magic-string'; // @ts-ignore import { walk, childKeys } from 'estree-walker'; import { getLocator } from 'locate-character'; import Stats from '../Stats'; import { globals, reserved } from '../utils/names'; import { namespaces, valid_namespaces } from '../utils/namespaces'; import create_module from './create_module'; import { create_scopes, extract_names, Scope, extract_identifiers } from './utils/scope'; import Stylesheet from './css/Stylesheet'; import { test } from '../config'; import Fragment from './nodes/Fragment'; import internal_exports from './internal_exports'; import { Node, Ast, CompileOptions, Var, Warning } from '../interfaces'; import error from '../utils/error'; import get_code_frame from '../utils/get_code_frame'; import flatten_reference from './utils/flatten_reference'; import is_reference from 'is-reference'; import TemplateScope from './nodes/shared/TemplateScope'; import fuzzymatch from '../utils/fuzzymatch'; import { remove_indentation, add_indentation } from '../utils/indentation'; import get_object from './utils/get_object'; import unwrap_parens from './utils/unwrap_parens'; import Slot from './nodes/Slot'; import { Node as ESTreeNode } from 'estree'; import add_to_set from './utils/add_to_set'; interface ComponentOptions { namespace?: string; tag?: string; immutable?: boolean; accessors?: boolean; preserveWhitespace?: boolean; } // 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']; childKeys.ExportNamedDeclaration = ['declaration', 'specifiers']; function remove_node(code: MagicString, start: number, end: number, body: Node, node: Node) { const i = body.indexOf(node); if (i === -1) throw new Error('node not in list'); let a; let b; if (body.length === 1) { // remove everything, leave {} a = start; b = end; } else if (i === 0) { // remove everything before second node, including comments a = start; while (/\s/.test(code.original[a])) a += 1; b = body[i].end; while (/[\s,]/.test(code.original[b])) b += 1; } else { // remove the end of the previous node to the end of this one a = body[i - 1].end; b = node.end; } code.remove(a, b); return; } export default class Component { stats: Stats; warnings: Warning[]; ignores: Set; ignore_stack: Array> = []; ast: Ast; source: string; code: MagicString; name: string; compile_options: CompileOptions; fragment: Fragment; module_scope: Scope; instance_scope: Scope; instance_scope_map: WeakMap; component_options: ComponentOptions; namespace: string; tag: string; accessors: boolean; vars: Var[] = []; var_lookup: Map = new Map(); imports: Node[] = []; module_javascript: string; javascript: string; hoistable_nodes: Set = new Set(); node_for_declaration: Map = new Map(); partly_hoisted: string[] = []; fully_hoisted: string[] = []; reactive_declarations: Array<{ assignees: Set; dependencies: Set; node: Node; declaration: Node }> = []; reactive_declaration_nodes: Set = new Set(); has_reactive_assignments = false; injected_reactive_declaration_vars: Set = new Set(); helpers: Map = new Map(); globals: Map = new Map(); indirect_dependencies: Map> = new Map(); file: string; locate: (c: number) => { line: number; column: number }; // TODO this does the same as component.locate! remove one or the other locator: (search: number, startIndex?: number) => { line: number; column: number; }; stylesheet: Stylesheet; aliases: Map = new Map(); used_names: Set = new Set(); globally_used_names: Set = new Set(); slots: Map = new Map(); slot_outlets: Set = new Set(); constructor( ast: Ast, source: string, name: string, compile_options: CompileOptions, stats: Stats, warnings: Warning[] ) { this.name = name; this.stats = stats; this.warnings = warnings; this.ast = ast; this.source = source; this.compile_options = compile_options; this.file = compile_options.filename && ( // eslint-disable-next-line no-useless-escape typeof process !== 'undefined' ? compile_options.filename.replace(process.cwd(), '').replace(/^[\/\\]/, '') : compile_options.filename ); this.locate = getLocator(this.source); this.code = new MagicString(source); // styles this.stylesheet = new Stylesheet(source, ast, compile_options.filename, compile_options.dev); this.stylesheet.validate(this); this.component_options = process_component_options(this, this.ast.html.children); this.namespace = namespaces[this.component_options.namespace] || this.component_options.namespace; if (compile_options.customElement) { if (this.component_options.tag === undefined && compile_options.tag === undefined) { const svelteOptions = ast.html.children.find(child => child.name === 'svelte:options') || { start: 0, end: 0 }; this.warn(svelteOptions, { code: 'custom-element-no-tag', message: `No custom element 'tag' option was specified. To automatically register a custom element, specify a name with a hyphen in it, e.g. . To hide this warning, use ` }); } this.tag = this.component_options.tag || compile_options.tag; } else { this.tag = this.name; } this.walk_module_js(); this.walk_instance_js_pre_template(); this.fragment = new Fragment(this, ast.html); this.name = this.get_unique_name(name); this.walk_instance_js_post_template(); if (!compile_options.customElement) this.stylesheet.reify(); this.stylesheet.warn_on_unused_selectors(this); } add_var(variable: Var) { this.vars.push(variable); this.var_lookup.set(variable.name, variable); } add_reference(name: string) { const variable = this.var_lookup.get(name); if (variable) { variable.referenced = true; } else if (name === '$$props') { this.add_var({ name, injected: true, referenced: true }); } else if (name[0] === '$') { this.add_var({ name, injected: true, referenced: true, mutated: true, writable: true }); const subscribable_name = name.slice(1); this.add_reference(subscribable_name); const variable = this.var_lookup.get(subscribable_name); if (variable) variable.subscribable = true; } else { this.used_names.add(name); } } add_sourcemap_locations(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.get_unique_name(name)); } return this.aliases.get(name); } helper(name: string) { const alias = this.alias(name); this.helpers.set(name, alias); return alias; } global(name: string) { const alias = this.alias(name); this.globals.set(name, alias); return alias; } generate(result: string) { let js = null; let css = null; if (result) { const { compile_options, name } = this; const { format = 'esm' } = compile_options; const banner = `/* ${this.file ? `${this.file} ` : ``}generated by Svelte v${"__VERSION__"} */`; result = result .replace(/__svelte:self__/g, this.name) .replace(compile_options.generate === 'ssr' ? /(@+|#+)(\w*(?:-\w*)?)/g : /(@+)(\w*(?:-\w*)?)/g, (_match: string, sigil: string, name: string) => { if (sigil === '@') { if (name[0] === '_') { return this.global(name.slice(1)); } if (!internal_exports.has(name)) { throw new Error(`compiler error: this shouldn't happen! generated code is trying to use inexistent internal '${name}'`); } if (compile_options.dev && internal_exports.has(`${name}Dev`)) { name = `${name}Dev`; } return this.helper(name); } return sigil.slice(1) + name; }); const referenced_globals = Array.from(this.globals, ([name, alias]) => name !== alias && ({ name, alias })).filter(Boolean); if (referenced_globals.length) { this.helper('globals'); } const imported_helpers = Array.from(this.helpers, ([name, alias]) => ({ name, alias })); const module = create_module( result, format, name, banner, compile_options.sveltePath, imported_helpers, referenced_globals, this.imports, this.vars.filter(variable => variable.module && variable.export_name).map(variable => ({ name: variable.name, as: variable.export_name })), this.source ); const parts = module.split('✂]'); const final_chunk = parts.pop(); const compiled = new Bundle({ separator: '' }); function add_string(str: string) { compiled.addSource({ content: new MagicString(str), }); } const { filename } = compile_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), }); } const pattern = /\[✂(\d+)-(\d+)$/; parts.forEach((str: string) => { const chunk = str.replace(pattern, ''); if (chunk) add_string(chunk); const match = pattern.exec(str); const snippet = this.code.snip(+match[1], +match[2]); compiled.addSource({ filename, content: snippet, }); }); add_string(final_chunk); css = compile_options.customElement ? { code: null, map: null } : this.stylesheet.render(compile_options.cssOutputFilename, true); js = { code: compiled.toString(), map: compiled.generateMap({ includeContent: true, file: compile_options.outputFilename, }) }; } return { js, css, ast: this.ast, warnings: this.warnings, vars: this.vars.filter(v => !v.global && !v.internal).map(v => ({ name: v.name, export_name: v.export_name || null, injected: v.injected || false, module: v.module || false, mutated: v.mutated || false, reassigned: v.reassigned || false, referenced: v.referenced || false, writable: v.writable || false })), stats: this.stats.render() }; } get_unique_name(name: string) { if (test) name = `${name}$`; let alias = name; for ( let i = 1; reserved.has(alias) || this.var_lookup.has(alias) || this.used_names.has(alias) || this.globally_used_names.has(alias); alias = `${name}_${i++}` ); this.used_names.add(alias); return alias; } get_unique_name_maker() { const local_used_names = new Set(); function add(name: string) { local_used_names.add(name); } reserved.forEach(add); internal_exports.forEach(add); this.var_lookup.forEach((_value, key) => add(key)); return (name: string) => { if (test) name = `${name}$`; let alias = name; for ( let i = 1; this.used_names.has(alias) || local_used_names.has(alias); alias = `${name}_${i++}` ); local_used_names.add(alias); this.globally_used_names.add(alias); return alias; }; } error( pos: { start: number; end: number; }, e: { code: string; message: string; } ) { error(e.message, { name: 'ValidationError', code: e.code, source: this.source, start: pos.start, end: pos.end, filename: this.compile_options.filename }); } warn( pos: { start: number; end: number; }, warning: { code: string; message: string; } ) { if (this.ignores && this.ignores.has(warning.code)) { return; } if (!this.locator) { this.locator = getLocator(this.source, { offsetLine: 1 }); } const start = this.locator(pos.start); const end = this.locator(pos.end); const frame = get_code_frame(this.source, start.line - 1, start.column); this.warnings.push({ code: warning.code, message: warning.message, frame, start, end, pos: pos.start, filename: this.compile_options.filename, toString: () => `${warning.message} (${start.line + 1}:${start.column})\n${frame}`, }); } extract_imports(content) { const { code } = this; content.body.forEach(node => { if (node.type === 'ImportDeclaration') { // imports need to be hoisted out of the IIFE remove_node(code, content.start, content.end, content.body, node); this.imports.push(node); } }); } extract_exports(content) { const { code } = this; content.body.forEach(node => { if (node.type === 'ExportDefaultDeclaration') { this.error(node, { code: `default-export`, message: `A component cannot have a default export` }); } if (node.type === 'ExportNamedDeclaration') { if (node.source) { this.error(node, { code: `not-implemented`, message: `A component currently cannot have an export ... from` }); } if (node.declaration) { if (node.declaration.type === 'VariableDeclaration') { node.declaration.declarations.forEach(declarator => { extract_names(declarator.id).forEach(name => { const variable = this.var_lookup.get(name); variable.export_name = name; }); }); } else { const { name } = node.declaration.id; const variable = this.var_lookup.get(name); variable.export_name = name; } code.remove(node.start, node.declaration.start); } else { remove_node(code, content.start, content.end, content.body, node); node.specifiers.forEach(specifier => { const variable = this.var_lookup.get(specifier.local.name); if (variable) { variable.export_name = specifier.exported.name; } else { // TODO what happens with `export { Math }` or some other global? } }); } } }); } extract_javascript(script) { const nodes_to_include = script.content.body.filter(node => { if (this.hoistable_nodes.has(node)) return false; if (this.reactive_declaration_nodes.has(node)) return false; if (node.type === 'ImportDeclaration') return false; if (node.type === 'ExportDeclaration' && node.specifiers.length > 0) return false; return true; }); if (nodes_to_include.length === 0) return null; let a = script.content.start; while (/\s/.test(this.source[a])) a += 1; let b = a; let result = ''; script.content.body.forEach((node) => { if (this.hoistable_nodes.has(node) || this.reactive_declaration_nodes.has(node)) { if (a !== b) result += `[✂${a}-${b}✂]`; a = node.end; } b = node.end; }); // while (/\s/.test(this.source[a - 1])) a -= 1; b = script.content.end; while (/\s/.test(this.source[b - 1])) b -= 1; if (a < b) result += `[✂${a}-${b}✂]`; return result || null; } walk_module_js() { const component = this; const script = this.ast.module; if (!script) return; walk(script.content, { enter(node) { if (node.type === 'LabeledStatement' && node.label.name === '$') { component.warn(node, { code: 'module-script-reactive-declaration', message: '$: has no effect in a module script' }); } } }); this.add_sourcemap_locations(script.content); const { scope, globals } = create_scopes(script.content); this.module_scope = scope; scope.declarations.forEach((node, name) => { if (name[0] === '$') { this.error(node, { code: 'illegal-declaration', message: `The $ prefix is reserved, and cannot be used for variable and import names` }); } this.add_var({ name, module: true, hoistable: true, writable: node.kind === 'var' || node.kind === 'let' }); }); globals.forEach((node, name) => { if (name[0] === '$') { this.error(node, { code: 'illegal-subscription', message: `Cannot reference store value inside