diff --git a/.gitignore b/.gitignore index ff8a8fb65..472328464 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ node_modules /test/cli/samples/*/actual /test/sourcemaps/samples/*/output.js /test/sourcemaps/samples/*/output.js.map +/test/sourcemaps/samples/*/output.css +/test/sourcemaps/samples/*/output.css.map /src/compile/shared.ts /store.umd.js /yarn-error.log diff --git a/src/Stats.ts b/src/Stats.ts index 613064f98..b2e16a805 100644 --- a/src/Stats.ts +++ b/src/Stats.ts @@ -1,5 +1,5 @@ import { Node, Warning } from './interfaces'; -import Compiler from './compile/Compiler'; +import Component from './compile/Component'; const now = (typeof process !== 'undefined' && process.hrtime) ? () => { @@ -64,7 +64,7 @@ export default class Stats { stop(label) { if (label !== this.currentTiming.label) { - throw new Error(`Mismatched timing labels`); + throw new Error(`Mismatched timing labels (expected ${this.currentTiming.label}, got ${label})`); } this.currentTiming.end = now(); @@ -73,14 +73,14 @@ export default class Stats { this.currentChildren = this.currentTiming ? this.currentTiming.children : this.timings; } - render(compiler: Compiler) { + render(component: Component) { const timings = Object.assign({ total: now() - this.startTime }, collapseTimings(this.timings)); // TODO would be good to have this info even // if options.generate is false - const imports = compiler && compiler.imports.map(node => { + const imports = component && component.imports.map(node => { return { source: node.source.value, specifiers: node.specifiers.map(specifier => { @@ -96,11 +96,11 @@ export default class Stats { } }); - const hooks: Record = compiler && { - oncreate: !!compiler.templateProperties.oncreate, - ondestroy: !!compiler.templateProperties.ondestroy, - onstate: !!compiler.templateProperties.onstate, - onupdate: !!compiler.templateProperties.onupdate + const hooks: Record = component && { + oncreate: !!component.templateProperties.oncreate, + ondestroy: !!component.templateProperties.ondestroy, + onstate: !!component.templateProperties.onstate, + onupdate: !!component.templateProperties.onupdate }; return { diff --git a/src/compile/Compiler.ts b/src/compile/Compiler.ts deleted file mode 100644 index 690b837d7..000000000 --- a/src/compile/Compiler.ts +++ /dev/null @@ -1,742 +0,0 @@ -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[]; - hasRestParam: boolean; -} - -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; - components: Set; - events: Set; - methods: Set; - animations: 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; - - file: string; - fileVar: string; - locate: (c: number) => { line: number, column: number }; - - stylesheet: Stylesheet; - - userVars: Set; - templateVars: Map; - aliases: Map; - usedNames: Set; - - 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.animations = 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.file = options.filename && ( - typeof process !== 'undefined' ? options.filename.replace(process.cwd(), '').replace(/^[\/\\]/, '') : options.filename - ); - 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.fileVar = options.dev && this.getUniqueName('file'); - - 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' || name === 'outros') { - // special case - const global = name === 'outros' - ? `_svelteOutros` - : `_svelteTransitionManager`; - - inlineHelpers += `\n\nvar ${this.alias(name)} = window.${global} || (window.${global} = ${code});\n\n`; - } else if (name === 'escaped' || name === 'missingComponent' || name === 'invalidAttributeNameCharacter') { - // 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, - 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', 'animations'].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(); - - 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]; - - const hasRestParam = ( - param.properties && - param.properties.some(prop => prop.type === 'RestElement') - ); - - if (param.type !== 'ObjectPattern' || hasRestParam) { - fullStateComputations.push({ key, deps: null, hasRestParam }); - } else { - const deps = param.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, hasRestParam: false }); - - 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.animations) { - templateProperties.animations.value.properties.forEach((property: Node) => { - addDeclaration(getName(property.key), property.value, false, 'animations'); - }); - } - - 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}✂]`; - } - } - } -} diff --git a/src/compile/Component.ts b/src/compile/Component.ts new file mode 100644 index 000000000..e153fc6b6 --- /dev/null +++ b/src/compile/Component.ts @@ -0,0 +1,947 @@ +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 reservedNames from '../utils/reservedNames'; +import namespaces from '../utils/namespaces'; +import { removeNode } from '../utils/removeNode'; +import nodeToString from '../utils/nodeToString'; +import wrapModule from './wrapModule'; +import annotateWithScopes 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'; +import { SsrTarget } from './ssr'; +import { Node, GenerateOptions, ShorthandImport, Ast, CompileOptions, CustomElementOptions } from '../interfaces'; +import error from '../utils/error'; +import getCodeFrame from '../utils/getCodeFrame'; +import checkForComputedKeys from './validate/js/utils/checkForComputedKeys'; +import checkForDupes from './validate/js/utils/checkForDupes'; +import propValidators from './validate/js/propValidators'; +import fuzzymatch from './validate/utils/fuzzymatch'; +import flattenReference from '../utils/flattenReference'; + +interface Computation { + key: string; + deps: string[]; + hasRestParam: boolean; +} + +interface Declaration { + type: string; + name: string; + node: Node; + block: 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) { + // TODO can we fold this into a different pass? + 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 Component { + stats: Stats; + + ast: Ast; + source: string; + name: string; + options: CompileOptions; + fragment: Fragment; + target: DomTarget | SsrTarget; + + customElement: CustomElementOptions; + tag: string; + props: string[]; + + properties: Map; + + defaultExport: Node; + imports: Node[]; + shorthandImports: ShorthandImport[]; + helpers: Set; + components: Set; + events: Set; + methods: Set; + animations: Set; + transitions: Set; + actions: Set; + importedComponents: Map; + namespace: string; + hasComponents: boolean; + computations: Computation[]; + templateProperties: Record; + slots: Set; + javascript: [string, string]; + + used: { + components: Set; + helpers: Set; + events: Set; + animations: Set; + transitions: Set; + actions: Set; + }; + + declarations: Declaration[]; + + refCallees: Node[]; + + code: MagicString; + + bindingGroups: string[]; + indirectDependencies: Map>; + expectedProperties: Set; + refs: Set; + + file: string; + fileVar: string; + locate: (c: number) => { line: number, column: number }; + + stylesheet: Stylesheet; + + userVars: Set; + templateVars: Map; + aliases: Map; + usedNames: Set; + + locator: (search: number, startIndex?: number) => { + line: number, + column: number + }; + + constructor( + ast: Ast, + source: string, + name: string, + options: CompileOptions, + stats: Stats, + target: DomTarget | SsrTarget + ) { + 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.animations = new Set(); + this.transitions = new Set(); + this.actions = new Set(); + this.importedComponents = new Map(); + this.slots = new Set(); + + this.used = { + components: new Set(), + helpers: new Set(), + events: new Set(), + animations: new Set(), + transitions: new Set(), + actions: new Set(), + }; + + this.declarations = []; + + this.refs = new Set(); + this.refCallees = []; + + this.bindingGroups = []; + this.indirectDependencies = new Map(); + + this.file = options.filename && ( + typeof process !== 'undefined' ? options.filename.replace(process.cwd(), '').replace(/^[\/\\]/, '') : options.filename + ); + 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); + + // styles + this.stylesheet = new Stylesheet(source, ast, options.filename, options.dev); + this.stylesheet.validate(this); + + // 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.fileVar = options.dev && this.getUniqueName('file'); + + this.computations = []; + this.templateProperties = {}; + this.properties = new Map(); + + this.walkJs(); + 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(); + + this.stylesheet.warnOnUnusedSelectors(options.onwarn); + + if (this.defaultExport) { + const categories = { + components: 'component', + // TODO helpers require a bit more work — need to analyse all expressions + // helpers: 'helper', + events: 'event definition', + transitions: 'transition', + actions: 'actions', + }; + + Object.keys(categories).forEach(category => { + const definitions = this.defaultExport.declaration.properties.find(prop => prop.key.name === category); + if (definitions) { + definitions.value.properties.forEach(prop => { + const { name } = prop.key; + if (!this.used[category].has(name)) { + this.warn(prop, { + code: `unused-${category.slice(0, -1)}`, + message: `The '${name}' ${categories[category]} is unused` + }); + } + }); + } + }); + } + + this.refCallees.forEach(callee => { + const { parts } = flattenReference(callee); + const ref = parts[1]; + + if (this.refs.has(ref)) { + // TODO check method is valid, e.g. `audio.stop()` should be `audio.pause()` + } else { + const match = fuzzymatch(ref, Array.from(this.refs.keys())); + + let message = `'refs.${ref}' does not exist`; + if (match) message += ` (did you mean 'refs.${match}'?)`; + + this.error(callee, { + code: `missing-ref`, + message + }); + } + }); + } + + 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 component = 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 = component.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' || name === 'outros') { + // special case + const global = name === 'outros' + ? `_svelteOutros` + : `_svelteTransitionManager`; + + inlineHelpers += `\n\nvar ${this.alias(name)} = window.${global} || (window.${global} = ${code});\n\n`; + } else if (name === 'escaped' || name === 'missingComponent' || name === 'invalidAttributeNameCharacter') { + // 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, + }) + }; + + 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; + }; + } + + 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.options.filename + }); + } + + warn( + pos: { + start: number, + end: number + }, + warning: { + code: string, + message: string + } + ) { + if (!this.locator) { + this.locator = getLocator(this.source, { offsetLine: 1 }); + } + + const start = this.locator(pos.start); + const end = this.locator(pos.end); + + const frame = getCodeFrame(this.source, start.line - 1, start.column); + + this.stats.warn({ + code: warning.code, + message: warning.message, + frame, + start, + end, + pos: pos.start, + filename: this.options.filename, + toString: () => `${warning.message} (${start.line + 1}:${start.column})\n${frame}`, + }); + } + + processDefaultExport(node, indentExclusionRanges) { + const { templateProperties, source, code } = this; + + if (node.declaration.type !== 'ObjectExpression') { + this.error(node.declaration, { + code: `invalid-default-export`, + message: `Default export must be an object literal` + }); + } + + checkForComputedKeys(this, node.declaration.properties); + checkForDupes(this, node.declaration.properties); + + const props = this.properties; + + node.declaration.properties.forEach((prop: Node) => { + props.set(getName(prop.key), prop); + }); + + const validPropList = Object.keys(propValidators); + + // ensure all exported props are valid + node.declaration.properties.forEach((prop: Node) => { + const name = getName(prop.key); + const propValidator = propValidators[name]; + + if (propValidator) { + propValidator(this, prop); + } else { + const match = fuzzymatch(name, validPropList); + if (match) { + this.error(prop, { + code: `unexpected-property`, + message: `Unexpected property '${name}' (did you mean '${match}'?)` + }); + } else if (/FunctionExpression/.test(prop.value.type)) { + this.error(prop, { + code: `unexpected-property`, + message: `Unexpected property '${name}' (did you mean to include it in 'methods'?)` + }); + } else { + this.error(prop, { + code: `unexpected-property`, + message: `Unexpected property '${name}'` + }); + } + } + }); + + if (props.has('namespace')) { + const ns = nodeToString(props.get('namespace').value); + this.namespace = namespaces[ns] || ns; + } + + node.declaration.properties.forEach((prop: Node) => { + templateProperties[getName(prop.key)] = prop; + }); + + ['helpers', 'events', 'components', 'transitions', 'actions', 'animations'].forEach(key => { + if (templateProperties[key]) { + templateProperties[key].value.properties.forEach((prop: Node) => { + this[key].add(getName(prop.key)); + }); + } + }); + + const addArrowFunctionExpression = (type: string, 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}✂]` : + ``; + + const block = body.type === 'BlockStatement' + ? deindent` + ${fnKeyword} ${name}(${paramString}) [✂${body.start}-${body.end}✂] + ` + : deindent` + ${fnKeyword} ${name}(${paramString}) { + return [✂${body.start}-${body.end}✂]; + } + `; + + this.declarations.push({ type, name, block, node }); + }; + + const addFunctionExpression = (type: string, name: string, node: Node) => { + const { async } = node; + const fnKeyword = async ? 'async function' : 'function'; + + let c = node.start; + while (this.source[c] !== '(') c += 1; + + const block = deindent` + ${fnKeyword} ${name}[✂${c}-${node.end}✂]; + `; + + this.declarations.push({ type, name, block, node }); + }; + + const addValue = (type: string, name: string, node: Node) => { + const block = deindent` + var ${name} = [✂${node.start}-${node.end}✂]; + `; + + this.declarations.push({ type, name, block, node }); + }; + + const addDeclaration = ( + type: string, + 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(type, name, node); + } else if (node.type === 'FunctionExpression') { + addFunctionExpression(type, name, node); + } else { + addValue(type, name, node); + } + }; + + if (templateProperties.components) { + templateProperties.components.value.properties.forEach((property: Node) => { + addDeclaration('components', 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('computed', key, value, false, 'computed', { + state: true, + changed: true + }); + + const param = value.params[0]; + + const hasRestParam = ( + param.properties && + param.properties.some(prop => prop.type === 'RestElement') + ); + + if (param.type !== 'ObjectPattern' || hasRestParam) { + fullStateComputations.push({ key, deps: null, hasRestParam }); + } else { + const deps = param.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); + + this.computations.push({ key, deps, hasRestParam: false }); + + 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) { + this.computations.push(...fullStateComputations); + } + } + + if (templateProperties.data) { + addDeclaration('data', 'data', templateProperties.data.value); + } + + if (templateProperties.events) { + templateProperties.events.value.properties.forEach((property: Node) => { + addDeclaration('events', getName(property.key), property.value, false, 'events'); + }); + } + + if (templateProperties.helpers) { + templateProperties.helpers.value.properties.forEach((property: Node) => { + addDeclaration('helpers', getName(property.key), property.value, false, 'helpers'); + }); + } + + if (templateProperties.methods) { + addDeclaration('methods', 'methods', templateProperties.methods.value); + + templateProperties.methods.value.properties.forEach(property => { + this.methods.add(getName(property.key)); + }); + } + + if (templateProperties.namespace) { + const ns = nodeToString(templateProperties.namespace.value); + this.namespace = namespaces[ns] || ns; + } + + if (templateProperties.oncreate) { + addDeclaration('oncreate', 'oncreate', templateProperties.oncreate.value); + } + + if (templateProperties.ondestroy) { + addDeclaration('ondestroy', 'ondestroy', templateProperties.ondestroy.value); + } + + if (templateProperties.onstate) { + addDeclaration('onstate', 'onstate', templateProperties.onstate.value); + } + + if (templateProperties.onupdate) { + addDeclaration('onupdate', 'onupdate', templateProperties.onupdate.value); + } + + if (templateProperties.preload) { + addDeclaration('preload', 'preload', templateProperties.preload.value); + } + + if (templateProperties.props) { + this.props = templateProperties.props.value.elements.map((element: Node) => nodeToString(element)); + } + + if (templateProperties.setup) { + addDeclaration('setup', 'setup', templateProperties.setup.value); + } + + if (templateProperties.store) { + addDeclaration('store', 'store', templateProperties.store.value); + } + + if (templateProperties.tag) { + this.tag = nodeToString(templateProperties.tag.value); + } + + if (templateProperties.transitions) { + templateProperties.transitions.value.properties.forEach((property: Node) => { + addDeclaration('transitions', getName(property.key), property.value, false, 'transitions'); + }); + } + + if (templateProperties.animations) { + templateProperties.animations.value.properties.forEach((property: Node) => { + addDeclaration('animations', getName(property.key), property.value, false, 'animations'); + }); + } + + if (templateProperties.actions) { + templateProperties.actions.value.properties.forEach((property: Node) => { + addDeclaration('actions', getName(property.key), property.value, false, 'actions'); + }); + } + + this.defaultExport = node; + } + + walkJs() { + const { js } = this.ast; + if (!js) return; + + this.addSourcemapLocations(js.content); + + const { code, source, imports } = this; + + 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 + + body.forEach(node => { + // check there are no named exports + if (node.type === 'ExportNamedDeclaration') { + this.error(node, { + code: `named-export`, + message: `A component can only have a default export` + }); + } + + if (node.type === 'ExportDefaultDeclaration') { + this.processDefaultExport(node, indentExclusionRanges); + } + + // imports need to be hoisted out of the IIFE + else if (node.type === 'ImportDeclaration') { + removeNode(code, js.content, node); + imports.push(node); + + node.specifiers.forEach((specifier: Node) => { + this.userVars.add(specifier.local.name); + }); + } + }); + + if (indentationLevel) { + if (this.defaultExport) { + removeIndentation(code, js.content.start, this.defaultExport.start, indentationLevel, indentExclusionRanges); + removeIndentation(code, this.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; + + this.javascript = this.defaultExport + ? [ + a !== this.defaultExport.start ? `[✂${a}-${this.defaultExport.start}✂]` : '', + b !== this.defaultExport.end ?`[✂${this.defaultExport.end}-${b}✂]` : '' + ] + : [ + a !== b ? `[✂${a}-${b}✂]` : '', + '' + ]; + } +} diff --git a/src/compile/dom/Block.ts b/src/compile/dom/Block.ts index ca3984b5d..3de06ec75 100644 --- a/src/compile/dom/Block.ts +++ b/src/compile/dom/Block.ts @@ -1,13 +1,12 @@ import CodeBuilder from '../../utils/CodeBuilder'; import deindent from '../../utils/deindent'; import { escape } from '../../utils/stringify'; -import Compiler from '../Compiler'; -import { Node } from '../../interfaces'; +import Component from '../Component'; export interface BlockOptions { parent?: Block; name: string; - compiler?: Compiler; + component?: Component; comment?: string; key?: string; bindings?: Map; @@ -16,7 +15,7 @@ export interface BlockOptions { export default class Block { parent?: Block; - compiler: Compiler; + component: Component; name: string; comment?: string; @@ -59,7 +58,7 @@ export default class Block { constructor(options: BlockOptions) { this.parent = options.parent; - this.compiler = options.compiler; + this.component = options.component; this.name = options.name; this.comment = options.comment; @@ -91,7 +90,7 @@ export default class Block { this.hasOutroMethod = false; this.outros = 0; - this.getUniqueName = this.compiler.getUniqueNameMaker(); + this.getUniqueName = this.component.getUniqueNameMaker(); this.variables = new Map(); this.aliases = new Map() @@ -129,11 +128,11 @@ export default class Block { } addIntro() { - this.hasIntros = this.hasIntroMethod = this.compiler.target.hasIntroTransitions = true; + this.hasIntros = this.hasIntroMethod = this.component.target.hasIntroTransitions = true; } addOutro() { - this.hasOutros = this.hasOutroMethod = this.compiler.target.hasOutroTransitions = true; + this.hasOutros = this.hasOutroMethod = this.component.target.hasOutroTransitions = true; this.outros += 1; } @@ -168,7 +167,7 @@ export default class Block { } toString() { - const { dev } = this.compiler.options; + const { dev } = this.component.options; if (this.hasIntroMethod || this.hasOutroMethod) { this.addVariable('#current'); @@ -203,7 +202,7 @@ export default class Block { properties.addBlock(`c: @noop,`); } else { const hydrate = !this.builders.hydrate.isEmpty() && ( - this.compiler.options.hydratable + this.component.options.hydratable ? `this.h()` : this.builders.hydrate ); @@ -216,7 +215,7 @@ export default class Block { `); } - if (this.compiler.options.hydratable) { + if (this.component.options.hydratable) { if (this.builders.claim.isEmpty() && this.builders.hydrate.isEmpty()) { properties.addBlock(`l: @noop,`); } else { @@ -229,7 +228,7 @@ export default class Block { } } - if (this.compiler.options.hydratable && !this.builders.hydrate.isEmpty()) { + if (this.component.options.hydratable && !this.builders.hydrate.isEmpty()) { properties.addBlock(deindent` ${dev ? 'h: function hydrate' : 'h'}() { ${this.builders.hydrate} diff --git a/src/compile/dom/index.ts b/src/compile/dom/index.ts index 3ced0254a..5780aca1d 100644 --- a/src/compile/dom/index.ts +++ b/src/compile/dom/index.ts @@ -1,19 +1,12 @@ -import MagicString from 'magic-string'; -import isReference from 'is-reference'; -import { parseExpressionAt } from 'acorn'; -import annotateWithScopes from '../../utils/annotateWithScopes'; -import { walk } from 'estree-walker'; import deindent from '../../utils/deindent'; import { stringify, escape } from '../../utils/stringify'; import CodeBuilder from '../../utils/CodeBuilder'; import globalWhitelist from '../../utils/globalWhitelist'; -import reservedNames from '../../utils/reservedNames'; -import Compiler from '../Compiler'; +import Component from '../Component'; import Stylesheet from '../../css/Stylesheet'; import Stats from '../../Stats'; import Block from './Block'; -import { test } from '../../config'; -import { Ast, CompileOptions, Node } from '../../interfaces'; +import { Ast, CompileOptions } from '../../interfaces'; export class DomTarget { blocks: (Block|string)[]; @@ -34,28 +27,21 @@ export class DomTarget { } export default function dom( - ast: Ast, - source: string, - stylesheet: Stylesheet, - options: CompileOptions, - stats: Stats + component: Component, + options: CompileOptions ) { const format = options.format || 'es'; - const target = new DomTarget(); - const compiler = new Compiler(ast, source, options.name || 'SvelteComponent', stylesheet, options, stats, true, target); - const { computations, name, - templateProperties, - namespace, - } = compiler; + templateProperties + } = component; - compiler.fragment.build(); - const { block } = compiler.fragment; + component.fragment.build(); + const { block } = component.fragment; - if (compiler.options.nestedTransitions) { + if (component.options.nestedTransitions) { block.hasOutroMethod = true; } @@ -68,14 +54,14 @@ export default function dom( if (computations.length) { computations.forEach(({ key, deps, hasRestParam }) => { - if (target.readonly.has(key)) { + if (component.target.readonly.has(key)) { // bindings throw new Error( `Cannot have a computed value '${key}' that clashes with a read-only property` ); } - target.readonly.add(key); + component.target.readonly.add(key); if (deps) { deps.forEach(dep => { @@ -96,31 +82,42 @@ export default function dom( }); } - if (compiler.javascript) { - builder.addBlock(compiler.javascript); + if (component.javascript) { + const componentDefinition = new CodeBuilder(); + component.declarations.forEach(declaration => { + componentDefinition.addBlock(declaration.block); + }); + + const js = ( + component.javascript[0] + + componentDefinition + + component.javascript[1] + ); + + builder.addBlock(js); } - if (compiler.options.dev) { - builder.addLine(`const ${compiler.fileVar} = ${JSON.stringify(compiler.file)};`); + if (component.options.dev) { + builder.addLine(`const ${component.fileVar} = ${JSON.stringify(component.file)};`); } - const css = compiler.stylesheet.render(options.filename, !compiler.customElement); - const styles = compiler.stylesheet.hasStyles && stringify(options.dev ? + const css = component.stylesheet.render(options.filename, !component.customElement); + const styles = component.stylesheet.hasStyles && stringify(options.dev ? `${css.code}\n/*# sourceMappingURL=${css.map.toUrl()} */` : css.code, { onlyEscapeAtSymbol: true }); - if (styles && compiler.options.css !== false && !compiler.customElement) { + if (styles && component.options.css !== false && !component.customElement) { builder.addBlock(deindent` function @add_css() { var style = @createElement("style"); - style.id = '${compiler.stylesheet.id}-style'; + style.id = '${component.stylesheet.id}-style'; style.textContent = ${styles}; @append(document.head, style); } `); } - target.blocks.forEach(block => { + component.target.blocks.forEach(block => { builder.addBlock(block.toString()); }); @@ -137,10 +134,10 @@ export default function dom( .join(',\n')} }`; - const debugName = `<${compiler.customElement ? compiler.tag : name}>`; + const debugName = `<${component.customElement ? component.tag : name}>`; // generate initial state object - const expectedProperties = Array.from(compiler.expectedProperties); + const expectedProperties = Array.from(component.expectedProperties); const globals = expectedProperties.filter(prop => globalWhitelist.has(prop)); const storeProps = expectedProperties.filter(prop => prop[0] === '$'); const initialState = []; @@ -165,32 +162,32 @@ export default function dom( const constructorBody = deindent` ${options.dev && `this._debugName = '${debugName}';`} - ${options.dev && !compiler.customElement && + ${options.dev && !component.customElement && `if (!options || (!options.target && !options.root)) throw new Error("'target' is a required option");`} @init(this, options); ${templateProperties.store && `this.store = %store();`} - ${compiler.usesRefs && `this.refs = {};`} + ${component.refs.size > 0 && `this.refs = {};`} this._state = ${initialState.reduce((state, piece) => `@assign(${state}, ${piece})`)}; ${storeProps.length > 0 && `this.store._add(this, [${storeProps.map(prop => `"${prop.slice(1)}"`)}]);`} - ${target.metaBindings} + ${component.target.metaBindings} ${computations.length && `this._recompute({ ${Array.from(computationDeps).map(dep => `${dep}: 1`).join(', ')} }, this._state);`} ${options.dev && - Array.from(compiler.expectedProperties).map(prop => { + Array.from(component.expectedProperties).map(prop => { if (globalWhitelist.has(prop)) return; if (computations.find(c => c.key === prop)) return; - const message = compiler.components.has(prop) ? + const message = component.components.has(prop) ? `${debugName} expected to find '${prop}' in \`data\`, but found it in \`components\` instead` : `${debugName} was created without expected data property '${prop}'`; const conditions = [`!('${prop}' in this._state)`]; - if (compiler.customElement) conditions.push(`!('${prop}' in this.attributes)`); + if (component.customElement) conditions.push(`!('${prop}' in this.attributes)`); return `if (${conditions.join(' && ')}) console.warn("${message}");` })} - ${compiler.bindingGroups.length && - `this._bindingGroups = [${Array(compiler.bindingGroups.length).fill('[]').join(', ')}];`} - this._intro = ${compiler.options.skipIntroByDefault ? '!!options.intro' : 'true'}; + ${component.bindingGroups.length && + `this._bindingGroups = [${Array(component.bindingGroups.length).fill('[]').join(', ')}];`} + this._intro = ${component.options.skipIntroByDefault ? '!!options.intro' : 'true'}; ${templateProperties.onstate && `this._handlers.state = [%onstate];`} ${templateProperties.onupdate && `this._handlers.update = [%onupdate];`} @@ -201,15 +198,15 @@ export default function dom( }];` )} - ${compiler.slots.size && `this._slotted = options.slots || {};`} + ${component.slots.size && `this._slotted = options.slots || {};`} - ${compiler.customElement ? + ${component.customElement ? deindent` this.attachShadow({ mode: 'open' }); ${css.code && `this.shadowRoot.innerHTML = \`\`;`} ` : - (compiler.stylesheet.hasStyles && options.css !== false && - `if (!document.getElementById("${compiler.stylesheet.id}-style")) @add_css();`) + (component.stylesheet.hasStyles && options.css !== false && + `if (!document.getElementById("${component.stylesheet.id}-style")) @add_css();`) } ${templateProperties.onstate && `%onstate.call(this, { changed: @assignTrue({}, this._state), current: this._state });`} @@ -223,14 +220,14 @@ export default function dom( }); `} - ${compiler.customElement ? deindent` + ${component.customElement ? deindent` this._fragment.c(); this._fragment.${block.hasIntroMethod ? 'i' : 'm'}(this.shadowRoot, null); if (options.target) this._mount(options.target, options.anchor); ` : deindent` if (options.target) { - ${compiler.options.hydratable + ${component.options.hydratable ? deindent` var nodes = @children(options.target); options.hydrate ? this._fragment.l(nodes) : this._fragment.c(); @@ -241,16 +238,16 @@ export default function dom( this._fragment.c();`} this._mount(options.target, options.anchor); - ${(compiler.hasComponents || target.hasComplexBindings || hasInitHooks || target.hasIntroTransitions) && + ${(component.hasComponents || component.target.hasComplexBindings || hasInitHooks || component.target.hasIntroTransitions) && `@flush(this);`} } `} - ${compiler.options.skipIntroByDefault && `this._intro = true;`} + ${component.options.skipIntroByDefault && `this._intro = true;`} `; - if (compiler.customElement) { - const props = compiler.props || Array.from(compiler.expectedProperties); + if (component.customElement) { + const props = component.props || Array.from(component.expectedProperties); builder.addBlock(deindent` class ${name} extends HTMLElement { @@ -273,7 +270,7 @@ export default function dom( } `).join('\n\n')} - ${compiler.slots.size && deindent` + ${component.slots.size && deindent` connectedCallback() { Object.keys(this._slotted).forEach(key => { this.appendChild(this._slotted[key]); @@ -284,7 +281,7 @@ export default function dom( this.set({ [attr]: newValue }); } - ${(compiler.hasComponents || target.hasComplexBindings || templateProperties.oncreate || target.hasIntroTransitions) && deindent` + ${(component.hasComponents || component.target.hasComplexBindings || templateProperties.oncreate || component.target.hasIntroTransitions) && deindent` connectedCallback() { @flush(this); } @@ -299,7 +296,7 @@ export default function dom( } }); - customElements.define("${compiler.tag}", ${name}); + customElements.define("${component.tag}", ${name}); `); } else { builder.addBlock(deindent` @@ -317,7 +314,7 @@ export default function dom( builder.addBlock(deindent` ${options.dev && deindent` ${name}.prototype._checkReadOnly = function _checkReadOnly(newState) { - ${Array.from(target.readonly).map( + ${Array.from(component.target.readonly).map( prop => `if ('${prop}' in newState && !this._updatingReadonlyProperty) throw new Error("${debugName}: Cannot set read-only property '${prop}'");` )} @@ -339,8 +336,8 @@ export default function dom( let result = builder.toString(); - return compiler.generate(result, options, { - banner: `/* ${compiler.file ? `${compiler.file} ` : ``}generated by Svelte v${"__VERSION__"} */`, + return component.generate(result, options, { + banner: `/* ${component.file ? `${component.file} ` : ``}generated by Svelte v${"__VERSION__"} */`, sharedPath, name, format, diff --git a/src/compile/index.ts b/src/compile/index.ts new file mode 100644 index 000000000..ccf917772 --- /dev/null +++ b/src/compile/index.ts @@ -0,0 +1,95 @@ +import { assign } from '../shared'; +import Stats from '../Stats'; +import parse from '../parse/index'; +import generate, { DomTarget } from './dom/index'; +import generateSSR, { SsrTarget } from './ssr/index'; +import { CompileOptions, Warning, Ast } from '../interfaces'; +import Component from './Component'; + +function normalize_options(options: CompileOptions): CompileOptions { + let normalized = assign({ generate: 'dom' }, options); + const { onwarn, onerror } = normalized; + + normalized.onwarn = onwarn + ? (warning: Warning) => onwarn(warning, default_onwarn) + : default_onwarn; + + normalized.onerror = onerror + ? (error: Error) => onerror(error, default_onerror) + : default_onerror; + + return normalized; +} + +function default_onwarn({ start, message }: Warning) { + if (start) { + console.warn(`(${start.line}:${start.column}) – ${message}`); + } else { + console.warn(message); + } +} + +function default_onerror(error: Error) { + throw error; +} + +function validate_options(options: CompileOptions, stats: Stats) { + const { name, filename } = options; + + if (name && !/^[a-zA-Z_$][a-zA-Z_$0-9]*$/.test(name)) { + const error = new Error(`options.name must be a valid identifier (got '${name}')`); + throw error; + } + + if (name && /^[a-z]/.test(name)) { + const message = `options.name should be capitalised`; + stats.warn({ + code: `options-lowercase-name`, + message, + filename, + toString: () => message, + }); + } +} + +export default function compile(source: string, options: CompileOptions) { + options = normalize_options(options); + + const stats = new Stats({ + onwarn: options.onwarn + }); + + let ast: Ast; + + try { + validate_options(options, stats); + + stats.start('parse'); + ast = parse(source, options); + stats.stop('parse'); + + stats.start('create component'); + const component = new Component( + ast, + source, + options.name || 'SvelteComponent', + options, + stats, + + // TODO make component generator-agnostic, to allow e.g. WebGL generator + options.generate === 'ssr' ? new SsrTarget() : new DomTarget() + ); + stats.stop('create component'); + + if (options.generate === false) { + return { ast, stats: stats.render(null), js: null, css: null }; + } + + const compiler = options.generate === 'ssr' ? generateSSR : generate; + + return compiler(component, options); + } catch (err) { + options.onerror(err); + return; + } +} \ No newline at end of file diff --git a/src/compile/nodes/Action.ts b/src/compile/nodes/Action.ts index 1e8f4311e..f9460d6c8 100644 --- a/src/compile/nodes/Action.ts +++ b/src/compile/nodes/Action.ts @@ -6,13 +6,22 @@ export default class Action extends Node { name: string; expression: Expression; - constructor(compiler, parent, scope, info) { - super(compiler, parent, scope, info); + constructor(component, parent, scope, info) { + super(component, parent, scope, info); this.name = info.name; + component.used.actions.add(this.name); + + if (!component.actions.has(this.name)) { + component.error(this, { + code: `missing-action`, + message: `Missing action '${this.name}'` + }); + } + this.expression = info.expression - ? new Expression(compiler, this, scope, info.expression) + ? new Expression(component, this, scope, info.expression) : null; } } \ No newline at end of file diff --git a/src/compile/nodes/Animation.ts b/src/compile/nodes/Animation.ts index cf9ec3514..4df3fec66 100644 --- a/src/compile/nodes/Animation.ts +++ b/src/compile/nodes/Animation.ts @@ -1,4 +1,3 @@ -import Block from '../dom/Block'; import Node from './shared/Node'; import Expression from './shared/Expression'; @@ -7,21 +6,40 @@ export default class Animation extends Node { name: string; expression: Expression; - constructor(compiler, parent, scope, info) { - super(compiler, parent, scope, info); + constructor(component, parent, scope, info) { + super(component, parent, scope, info); this.name = info.name; - this.expression = info.expression - ? new Expression(compiler, this, scope, info.expression) - : null; - } + component.used.animations.add(this.name); + + if (parent.animation) { + component.error(this, { + code: `duplicate-animation`, + message: `An element can only have one 'animate' directive` + }); + } - build( - block: Block, - parentNode: string, - parentNodes: string - ) { + if (!component.animations.has(this.name)) { + component.error(this, { + code: `missing-animation`, + message: `Missing animation '${this.name}'` + }); + } + const block = parent.parent; + if (!block || block.type !== 'EachBlock' || !block.key) { + // TODO can we relax the 'immediate child' rule? + component.error(this, { + code: `invalid-animation`, + message: `An element that use the animate directive must be the immediate child of a keyed each block` + }); + } + + block.hasAnimation = true; + + this.expression = info.expression + ? new Expression(component, this, scope, info.expression) + : null; } } \ No newline at end of file diff --git a/src/compile/nodes/Attribute.ts b/src/compile/nodes/Attribute.ts index b0c26c534..caf5f7ed2 100644 --- a/src/compile/nodes/Attribute.ts +++ b/src/compile/nodes/Attribute.ts @@ -2,7 +2,7 @@ import deindent from '../../utils/deindent'; import { escape, escapeTemplate, stringify } from '../../utils/stringify'; import fixAttributeCasing from '../../utils/fixAttributeCasing'; import addToSet from '../../utils/addToSet'; -import Compiler from '../Compiler'; +import Component from '../Component'; import Node from './shared/Node'; import Element from './Element'; import Text from './Text'; @@ -19,7 +19,7 @@ export default class Attribute extends Node { start: number; end: number; - compiler: Compiler; + component: Component; parent: Element; name: string; isSpread: boolean; @@ -31,8 +31,8 @@ export default class Attribute extends Node { chunks: (Text | Expression)[]; dependencies: Set; - constructor(compiler, parent, scope, info) { - super(compiler, parent, scope, info); + constructor(component, parent, scope, info) { + super(component, parent, scope, info); if (info.type === 'Spread') { this.name = null; @@ -40,7 +40,7 @@ export default class Attribute extends Node { this.isTrue = false; this.isSynthetic = false; - this.expression = new Expression(compiler, this, scope, info.expression); + this.expression = new Expression(component, this, scope, info.expression); this.dependencies = this.expression.dependencies; this.chunks = null; @@ -60,7 +60,7 @@ export default class Attribute extends Node { : info.value.map(node => { if (node.type === 'Text') return node; - const expression = new Expression(compiler, this, scope, node.expression); + const expression = new Expression(component, this, scope, node.expression); addToSet(this.dependencies, expression.dependencies); return expression; @@ -98,6 +98,16 @@ export default class Attribute extends Node { .join(' + '); } + getStaticValue() { + if (this.isSpread || this.isDynamic) return null; + + return this.isTrue + ? true + : this.chunks[0] + ? this.chunks[0].data + : ''; + } + render(block: Block) { const node = this.parent; const name = fixAttributeCasing(this.name); @@ -136,9 +146,9 @@ export default class Attribute extends Node { ? '@setXlinkAttribute' : '@setAttribute'; - const isLegacyInputType = this.compiler.options.legacy && name === 'type' && this.parent.name === 'input'; + const isLegacyInputType = this.component.options.legacy && name === 'type' && this.parent.name === 'input'; - const isDataSet = /^data-/.test(name) && !this.compiler.options.legacy && !node.namespace; + const isDataSet = /^data-/.test(name) && !this.component.options.legacy && !node.namespace; const camelCaseName = isDataSet ? name.replace('data-', '').replace(/(-\w)/g, function (m) { return m[1].toUpperCase(); }) : name; diff --git a/src/compile/nodes/AwaitBlock.ts b/src/compile/nodes/AwaitBlock.ts index 4897adcd5..df30f828a 100644 --- a/src/compile/nodes/AwaitBlock.ts +++ b/src/compile/nodes/AwaitBlock.ts @@ -17,18 +17,18 @@ export default class AwaitBlock extends Node { then: ThenBlock; catch: CatchBlock; - constructor(compiler, parent, scope, info) { - super(compiler, parent, scope, info); + constructor(component, parent, scope, info) { + super(component, parent, scope, info); - this.expression = new Expression(compiler, this, scope, info.expression); + this.expression = new Expression(component, this, scope, info.expression); const deps = this.expression.dependencies; this.value = info.value; this.error = info.error; - this.pending = new PendingBlock(compiler, this, scope, info.pending); - this.then = new ThenBlock(compiler, this, scope.add(this.value, deps), info.then); - this.catch = new CatchBlock(compiler, this, scope.add(this.error, deps), info.catch); + this.pending = new PendingBlock(component, this, scope, info.pending); + this.then = new ThenBlock(component, this, scope.add(this.value, deps), info.then); + this.catch = new CatchBlock(component, this, scope.add(this.error, deps), info.catch); } init( @@ -49,12 +49,12 @@ export default class AwaitBlock extends Node { const child = this[status]; child.block = block.child({ - comment: createDebuggingComment(child, this.compiler), - name: this.compiler.getUniqueName(`create_${status}_block`) + comment: createDebuggingComment(child, this.component), + name: this.component.getUniqueName(`create_${status}_block`) }); child.initChildren(child.block, stripWhitespace, nextSibling); - this.compiler.target.blocks.push(child.block); + this.component.target.blocks.push(child.block); if (child.block.dependencies.size > 0) { isDynamic = true; @@ -77,7 +77,7 @@ export default class AwaitBlock extends Node { this.then.block.hasOutroMethod = hasOutros; this.catch.block.hasOutroMethod = hasOutros; - if (hasOutros && this.compiler.options.nestedTransitions) block.addOutro(); + if (hasOutros && this.component.options.nestedTransitions) block.addOutro(); } build( @@ -171,7 +171,7 @@ export default class AwaitBlock extends Node { `); } - if (this.pending.block.hasOutroMethod && this.compiler.options.nestedTransitions) { + if (this.pending.block.hasOutroMethod && this.component.options.nestedTransitions) { const countdown = block.getUniqueName('countdown'); block.builders.outro.addBlock(deindent` const ${countdown} = @callAfter(#outrocallback, 3); @@ -196,7 +196,7 @@ export default class AwaitBlock extends Node { } ssr() { - const target: SsrTarget = this.compiler.target; + const target: SsrTarget = this.component.target; const { snippet } = this.expression; target.append('${(function(__value) { if(@isPromise(__value)) return `'); diff --git a/src/compile/nodes/Binding.ts b/src/compile/nodes/Binding.ts index 92a8ae1a3..40ad3bce5 100644 --- a/src/compile/nodes/Binding.ts +++ b/src/compile/nodes/Binding.ts @@ -3,7 +3,7 @@ import Element from './Element'; import getObject from '../../utils/getObject'; import getTailSnippet from '../../utils/getTailSnippet'; import flattenReference from '../../utils/flattenReference'; -import Compiler from '../Compiler'; +import Component from '../Component'; import Block from '../dom/Block'; import Expression from './shared/Expression'; import { dimensions } from '../../utils/patterns'; @@ -16,7 +16,7 @@ const readOnlyMediaAttributes = new Set([ ]); // TODO a lot of this element-specific stuff should live in Element — -// Binding should ideally be agnostic between Element and Component +// Binding should ideally be agnostic between Element and InlineComponent export default class Binding extends Node { name: string; @@ -26,11 +26,11 @@ export default class Binding extends Node { obj: string; prop: string; - constructor(compiler, parent, scope, info) { - super(compiler, parent, scope, info); + constructor(component, parent, scope, info) { + super(component, parent, scope, info); this.name = info.name; - this.value = new Expression(compiler, this, scope, info.value); + this.value = new Expression(component, this, scope, info.value); let obj; let prop; @@ -78,7 +78,7 @@ export default class Binding extends Node { // TODO should this happen in preprocess? const dependencies = new Set(this.value.dependencies); this.value.dependencies.forEach((prop: string) => { - const indirectDependencies = this.compiler.indirectDependencies.get(prop); + const indirectDependencies = this.component.indirectDependencies.get(prop); if (indirectDependencies) { indirectDependencies.forEach(indirectDependency => { dependencies.add(indirectDependency); @@ -87,8 +87,8 @@ export default class Binding extends Node { }); // view to model - const valueFromDom = getValueFromDom(this.compiler, node, this); - const handler = getEventHandler(this, this.compiler, block, name, snippet, dependencies, valueFromDom); + const valueFromDom = getValueFromDom(this.component, node, this); + const handler = getEventHandler(this, this.component, block, name, snippet, dependencies, valueFromDom); // model to view let updateDom = getDomUpdater(node, this, snippet); @@ -96,7 +96,7 @@ export default class Binding extends Node { // special cases if (this.name === 'group') { - const bindingGroup = getBindingGroup(this.compiler, this.value.node); + const bindingGroup = getBindingGroup(this.component, this.value.node); block.builders.hydrate.addLine( `#component._bindingGroups[${bindingGroup}].push(${node.var});` @@ -184,16 +184,16 @@ function getDomUpdater( return `${node.var}.${binding.name} = ${snippet};`; } -function getBindingGroup(compiler: Compiler, value: Node) { +function getBindingGroup(component: Component, value: Node) { const { parts } = flattenReference(value); // TODO handle cases involving computed member expressions const keypath = parts.join('.'); // TODO handle contextual bindings — `keypath` should include unique ID of // each block that provides context - let index = compiler.bindingGroups.indexOf(keypath); + let index = component.bindingGroups.indexOf(keypath); if (index === -1) { - index = compiler.bindingGroups.length; - compiler.bindingGroups.push(keypath); + index = component.bindingGroups.length; + component.bindingGroups.push(keypath); } return index; @@ -201,7 +201,7 @@ function getBindingGroup(compiler: Compiler, value: Node) { function getEventHandler( binding: Binding, - compiler: Compiler, + component: Component, block: Block, name: string, snippet: string, @@ -234,9 +234,9 @@ function getEventHandler( // Svelte tries to `set()` a computed property, which throws an // error in dev mode. a) it's possible that we should be // replacing computations with *their* dependencies, and b) - // we should probably populate `compiler.target.readonly` sooner so + // we should probably populate `component.target.readonly` sooner so // that we don't have to do the `.some()` here - dependenciesArray = dependenciesArray.filter(prop => !compiler.computations.some(computation => computation.key === prop)); + dependenciesArray = dependenciesArray.filter(prop => !component.computations.some(computation => computation.key === prop)); return { usesContext: false, @@ -270,7 +270,7 @@ function getEventHandler( } function getValueFromDom( - compiler: Compiler, + component: Component, node: Element, binding: Node ) { @@ -285,7 +285,7 @@ function getValueFromDom( // if (binding.name === 'group') { - const bindingGroup = getBindingGroup(compiler, binding.value.node); + const bindingGroup = getBindingGroup(component, binding.value.node); if (type === 'checkbox') { return `@getBindingGroupValue(#component._bindingGroups[${bindingGroup}])`; } diff --git a/src/compile/nodes/CatchBlock.ts b/src/compile/nodes/CatchBlock.ts index db15f7ceb..4fb9ae43f 100644 --- a/src/compile/nodes/CatchBlock.ts +++ b/src/compile/nodes/CatchBlock.ts @@ -6,8 +6,10 @@ export default class CatchBlock extends Node { block: Block; children: Node[]; - constructor(compiler, parent, scope, info) { - super(compiler, parent, scope, info); - this.children = mapChildren(compiler, parent, scope, info.children); + constructor(component, parent, scope, info) { + super(component, parent, scope, info); + this.children = mapChildren(component, parent, scope, info.children); + + this.warnIfEmptyBlock(); } } \ No newline at end of file diff --git a/src/compile/nodes/Class.ts b/src/compile/nodes/Class.ts index 965cf7ea0..75000371d 100644 --- a/src/compile/nodes/Class.ts +++ b/src/compile/nodes/Class.ts @@ -6,13 +6,13 @@ export default class Class extends Node { name: string; expression: Expression; - constructor(compiler, parent, scope, info) { - super(compiler, parent, scope, info); + constructor(component, parent, scope, info) { + super(component, parent, scope, info); this.name = info.name; this.expression = info.expression - ? new Expression(compiler, this, scope, info.expression) + ? new Expression(component, this, scope, info.expression) : null; } } \ No newline at end of file diff --git a/src/compile/nodes/Comment.ts b/src/compile/nodes/Comment.ts index ba20f5750..03ea0be9e 100644 --- a/src/compile/nodes/Comment.ts +++ b/src/compile/nodes/Comment.ts @@ -4,15 +4,15 @@ export default class Comment extends Node { type: 'Comment'; data: string; - constructor(compiler, parent, scope, info) { - super(compiler, parent, scope, info); + constructor(component, parent, scope, info) { + super(component, parent, scope, info); this.data = info.data; } ssr() { // Allow option to preserve comments, otherwise ignore - if (this.compiler.options.preserveComments) { - this.compiler.target.append(``); + if (this.component.options.preserveComments) { + this.component.target.append(``); } } } \ No newline at end of file diff --git a/src/compile/nodes/DebugTag.ts b/src/compile/nodes/DebugTag.ts index b73208961..9768b5c12 100644 --- a/src/compile/nodes/DebugTag.ts +++ b/src/compile/nodes/DebugTag.ts @@ -9,11 +9,11 @@ import { stringify } from '../../utils/stringify'; export default class DebugTag extends Node { expressions: Expression[]; - constructor(compiler, parent, scope, info) { - super(compiler, parent, scope, info); + constructor(component, parent, scope, info) { + super(component, parent, scope, info); this.expressions = info.identifiers.map(node => { - return new Expression(compiler, parent, scope, node); + return new Expression(component, parent, scope, node); }); } @@ -22,9 +22,9 @@ export default class DebugTag extends Node { parentNode: string, parentNodes: string, ) { - if (!this.compiler.options.dev) return; + if (!this.component.options.dev) return; - const { code } = this.compiler; + const { code } = this.component; if (this.expressions.length === 0) { // Debug all @@ -36,7 +36,7 @@ export default class DebugTag extends Node { block.builders.create.addLine(statement); block.builders.update.addLine(statement); } else { - const { code } = this.compiler; + const { code } = this.component; code.overwrite(this.start + 1, this.start + 7, 'log', { storeName: true }); @@ -70,10 +70,10 @@ export default class DebugTag extends Node { } ssr() { - if (!this.compiler.options.dev) return; + if (!this.component.options.dev) return; - const filename = this.compiler.file || null; - const { line, column } = this.compiler.locate(this.start + 1); + const filename = this.component.file || null; + const { line, column } = this.component.locate(this.start + 1); const obj = this.expressions.length === 0 ? `ctx` @@ -84,6 +84,6 @@ export default class DebugTag extends Node { const str = '${@debug(' + `${filename && stringify(filename)}, ${line}, ${column}, ${obj})}`; - this.compiler.target.append(str); + this.component.target.append(str); } } \ No newline at end of file diff --git a/src/compile/nodes/EachBlock.ts b/src/compile/nodes/EachBlock.ts index 2a62249c6..60e814afa 100644 --- a/src/compile/nodes/EachBlock.ts +++ b/src/compile/nodes/EachBlock.ts @@ -20,14 +20,15 @@ export default class EachBlock extends Node { key: Expression; scope: TemplateScope; contexts: Array<{ name: string, tail: string }>; + hasAnimation: boolean; children: Node[]; else?: ElseBlock; - constructor(compiler, parent, scope, info) { - super(compiler, parent, scope, info); + constructor(component, parent, scope, info) { + super(component, parent, scope, info); - this.expression = new Expression(compiler, this, scope, info.expression); + this.expression = new Expression(component, this, scope, info.expression); this.context = info.context.name || 'each'; // TODO this is used to facilitate binding; currently fails with destructuring this.index = info.index; @@ -37,11 +38,18 @@ export default class EachBlock extends Node { unpackDestructuring(this.contexts, info.context, ''); this.contexts.forEach(context => { + if (component.helpers.has(context.key.name)) { + component.warn(context.key, { + code: `each-context-clash`, + message: `Context clashes with a helper. Rename one or the other to eliminate any ambiguity` + }); + } + this.scope.add(context.key.name, this.expression.dependencies); }); this.key = info.key - ? new Expression(compiler, this, this.scope, info.key) + ? new Expression(component, this, this.scope, info.key) : null; if (this.index) { @@ -50,10 +58,24 @@ export default class EachBlock extends Node { this.scope.add(this.index, dependencies); } - this.children = mapChildren(compiler, this, this.scope, info.children); + this.hasAnimation = false; + + this.children = mapChildren(component, this, this.scope, info.children); + + if (this.hasAnimation) { + if (this.children.length !== 1) { + const child = this.children.find(child => !!child.animation); + component.error(child.animation, { + code: `invalid-animation`, + message: `An element that use the animate directive must be the sole child of a keyed each block` + }); + } + } + + this.warnIfEmptyBlock(); // TODO would be better if EachBlock, IfBlock etc extended an abstract Block class this.else = info.else - ? new ElseBlock(compiler, this, this.scope, info.else) + ? new ElseBlock(component, this, this.scope, info.else) : null; } @@ -66,22 +88,22 @@ export default class EachBlock extends Node { this.var = block.getUniqueName(`each`); this.iterations = block.getUniqueName(`${this.var}_blocks`); - this.get_each_context = this.compiler.getUniqueName(`get_${this.var}_context`); + this.get_each_context = this.component.getUniqueName(`get_${this.var}_context`); const { dependencies } = this.expression; block.addDependencies(dependencies); this.block = block.child({ - comment: createDebuggingComment(this, this.compiler), - name: this.compiler.getUniqueName('create_each_block'), + comment: createDebuggingComment(this, this.component), + name: this.component.getUniqueName('create_each_block'), key: this.key, bindings: new Map(block.bindings) }); - this.each_block_value = this.compiler.getUniqueName('each_value'); + this.each_block_value = this.component.getUniqueName('each_value'); - const indexName = this.index || this.compiler.getUniqueName(`${this.context}_index`); + const indexName = this.index || this.component.getUniqueName(`${this.context}_index`); this.contexts.forEach(prop => { this.block.bindings.set(prop.key.name, `ctx.${this.each_block_value}[ctx.${indexName}]${prop.tail}`); @@ -99,18 +121,18 @@ export default class EachBlock extends Node { `child_ctx.${indexName} = i;` ); - this.compiler.target.blocks.push(this.block); + this.component.target.blocks.push(this.block); this.initChildren(this.block, stripWhitespace, nextSibling); block.addDependencies(this.block.dependencies); this.block.hasUpdateMethod = this.block.dependencies.size > 0; if (this.else) { this.else.block = block.child({ - comment: createDebuggingComment(this.else, this.compiler), - name: this.compiler.getUniqueName(`${this.block.name}_else`), + comment: createDebuggingComment(this.else, this.component), + name: this.component.getUniqueName(`${this.block.name}_else`), }); - this.compiler.target.blocks.push(this.else.block); + this.component.target.blocks.push(this.else.block); this.else.initChildren( this.else.block, stripWhitespace, @@ -131,7 +153,7 @@ export default class EachBlock extends Node { ) { if (this.children.length === 0) return; - const { compiler } = this; + const { component } = this; const each = this.var; @@ -146,8 +168,8 @@ export default class EachBlock extends Node { // hack the sourcemap, so that if data is missing the bug // is easy to find let c = this.start + 2; - while (compiler.source[c] !== 'e') c += 1; - compiler.code.overwrite(c, c + 4, 'length'); + while (component.source[c] !== 'e') c += 1; + component.code.overwrite(c, c + 4, 'length'); const length = `[✂${c}-${c+4}✂]`; const mountOrIntro = (this.block.hasIntroMethod || this.block.hasOutroMethod) ? 'i' : 'm'; @@ -164,7 +186,7 @@ export default class EachBlock extends Node { block.builders.init.addLine(`var ${this.each_block_value} = ${snippet};`); - this.compiler.target.blocks.push(deindent` + this.component.target.blocks.push(deindent` function ${this.get_each_context}(ctx, list, i) { const child_ctx = Object.create(ctx); ${this.contextProps} @@ -188,7 +210,7 @@ export default class EachBlock extends Node { } if (this.else) { - const each_block_else = compiler.getUniqueName(`${each}_else`); + const each_block_else = component.getUniqueName(`${each}_else`); const mountOrIntro = (this.else.block.hasIntroMethod || this.else.block.hasOutroMethod) ? 'i' : 'm'; block.builders.init.addLine(`var ${each_block_else} = null;`); @@ -331,7 +353,7 @@ export default class EachBlock extends Node { ${this.block.hasAnimation && `for (let #i = 0; #i < ${blocks}.length; #i += 1) ${blocks}[#i].a();`} `); - if (this.block.hasOutros && this.compiler.options.nestedTransitions) { + if (this.block.hasOutros && this.component.options.nestedTransitions) { const countdown = block.getUniqueName('countdown'); block.builders.outro.addBlock(deindent` const ${countdown} = @callAfter(#outrocallback, ${blocks}.length); @@ -477,7 +499,7 @@ export default class EachBlock extends Node { `); } - if (outroBlock && this.compiler.options.nestedTransitions) { + if (outroBlock && this.component.options.nestedTransitions) { const countdown = block.getUniqueName('countdown'); block.builders.outro.addBlock(deindent` ${iterations} = ${iterations}.filter(Boolean); @@ -495,7 +517,7 @@ export default class EachBlock extends Node { } ssr() { - const { compiler } = this; + const { component } = this; const { snippet } = this.expression; const props = this.contexts.map(prop => `${prop.key.name}: item${prop.tail}`); @@ -505,23 +527,23 @@ export default class EachBlock extends Node { : `item => Object.assign({}, ctx, { ${props.join(', ')} })`; const open = `\${ ${this.else ? `${snippet}.length ? ` : ''}@each(${snippet}, ${getContext}, ctx => \``; - compiler.target.append(open); + component.target.append(open); this.children.forEach((child: Node) => { child.ssr(); }); const close = `\`)`; - compiler.target.append(close); + component.target.append(close); if (this.else) { - compiler.target.append(` : \``); + component.target.append(` : \``); this.else.children.forEach((child: Node) => { child.ssr(); }); - compiler.target.append(`\``); + component.target.append(`\``); } - compiler.target.append('}'); + component.target.append('}'); } } diff --git a/src/compile/nodes/Element.ts b/src/compile/nodes/Element.ts index 45a01c7bc..32f0b04e3 100644 --- a/src/compile/nodes/Element.ts +++ b/src/compile/nodes/Element.ts @@ -6,7 +6,7 @@ import validCalleeObjects from '../../utils/validCalleeObjects'; import reservedNames from '../../utils/reservedNames'; import fixAttributeCasing from '../../utils/fixAttributeCasing'; import { quoteNameIfNecessary, quotePropIfNecessary } from '../../utils/quoteIfNecessary'; -import Compiler from '../Compiler'; +import Component from '../Component'; import Node from './shared/Node'; import Block from '../dom/Block'; import Attribute from './Attribute'; @@ -20,6 +20,8 @@ import Text from './Text'; import * as namespaces from '../../utils/namespaces'; import mapChildren from './shared/mapChildren'; import { dimensions } from '../../utils/patterns'; +import fuzzymatch from '../validate/utils/fuzzymatch'; +import Ref from './Ref'; // source: https://gist.github.com/ArjanSchouten/0b8574a6ad7f5065a5e7 const booleanAttributes = new Set([ @@ -62,6 +64,47 @@ const booleanAttributes = new Set([ 'translate' ]); +const svg = /^(?:altGlyph|altGlyphDef|altGlyphItem|animate|animateColor|animateMotion|animateTransform|circle|clipPath|color-profile|cursor|defs|desc|discard|ellipse|feBlend|feColorMatrix|feComponentTransfer|feComposite|feConvolveMatrix|feDiffuseLighting|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feImage|feMerge|feMergeNode|feMorphology|feOffset|fePointLight|feSpecularLighting|feSpotLight|feTile|feTurbulence|filter|font|font-face|font-face-format|font-face-name|font-face-src|font-face-uri|foreignObject|g|glyph|glyphRef|hatch|hatchpath|hkern|image|line|linearGradient|marker|mask|mesh|meshgradient|meshpatch|meshrow|metadata|missing-glyph|mpath|path|pattern|polygon|polyline|radialGradient|rect|set|solidcolor|stop|switch|symbol|text|textPath|tref|tspan|unknown|use|view|vkern)$/; + +const ariaAttributes = 'activedescendant atomic autocomplete busy checked controls current describedby details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription selected setsize sort valuemax valuemin valuenow valuetext'.split(' '); +const ariaAttributeSet = new Set(ariaAttributes); + +const ariaRoles = 'alert alertdialog application article banner button cell checkbox columnheader combobox command complementary composite contentinfo definition dialog directory document feed figure form grid gridcell group heading img input landmark link list listbox listitem log main marquee math menu menubar menuitem menuitemcheckbox menuitemradio navigation none note option presentation progressbar radio radiogroup range region roletype row rowgroup rowheader scrollbar search searchbox section sectionhead select separator slider spinbutton status structure switch tab table tablist tabpanel term textbox timer toolbar tooltip tree treegrid treeitem widget window'.split(' '); +const ariaRoleSet = new Set(ariaRoles); + +const a11yRequiredAttributes = { + a: ['href'], + area: ['alt', 'aria-label', 'aria-labelledby'], + + // html-has-lang + html: ['lang'], + + // iframe-has-title + iframe: ['title'], + img: ['alt'], + object: ['title', 'aria-label', 'aria-labelledby'] +}; + +const a11yDistractingElements = new Set([ + 'blink', + 'marquee' +]); + +const a11yRequiredContent = new Set([ + // anchor-has-content + 'a', + + // heading-has-content + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6' +]) + +const invisibleElements = new Set(['meta', 'html', 'script', 'style']); + export default class Element extends Node { type: 'Element'; name: string; @@ -77,18 +120,25 @@ export default class Element extends Node { animation?: Animation; children: Node[]; - ref: string; + ref: Ref; namespace: string; - constructor(compiler, parent, scope, info: any) { - super(compiler, parent, scope, info); + constructor(component, parent, scope, info: any) { + super(component, parent, scope, info); this.name = info.name; this.scope = scope; const parentElement = parent.findNearest(/^Element/); this.namespace = this.name === 'svg' ? namespaces.svg : - parentElement ? parentElement.namespace : this.compiler.namespace; + parentElement ? parentElement.namespace : this.component.namespace; + + if (!this.namespace && svg.test(this.name)) { + this.component.warn(this, { + code: `missing-namespace`, + message: `<${this.name}> is an SVG element – did you forget to add { namespace: 'svg' } ?` + }); + } this.attributes = []; this.actions = []; @@ -102,9 +152,17 @@ export default class Element extends Node { this.animation = null; if (this.name === 'textarea') { - // this is an egregious hack, but it's the easiest way to get