From 1dd92157143a6035e697b1b035a440e2a78cd522 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 9 Sep 2018 10:36:35 -0400 Subject: [PATCH] start refactoring, moving validation into nodes --- src/Stats.ts | 2 +- src/compile/Component.ts | 623 ++++++++++++++-------- src/compile/dom/index.ts | 27 +- src/compile/index.ts | 54 +- src/compile/nodes/Window.ts | 62 ++- src/compile/nodes/shared/Node.ts | 4 + src/compile/ssr/index.ts | 16 +- src/validate/html/validateEventHandler.ts | 22 +- src/validate/html/validateWindow.ts | 7 +- 9 files changed, 523 insertions(+), 294 deletions(-) diff --git a/src/Stats.ts b/src/Stats.ts index 4c8d2817c6..b2e16a805c 100644 --- a/src/Stats.ts +++ b/src/Stats.ts @@ -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(); diff --git a/src/compile/Component.ts b/src/compile/Component.ts index db43ce2f91..f1a28a924e 100644 --- a/src/compile/Component.ts +++ b/src/compile/Component.ts @@ -20,6 +20,12 @@ 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'; interface Computation { key: string; @@ -92,6 +98,8 @@ export default class Component { tag: string; props: string[]; + properties: Map; + defaultExport: Node[]; imports: Node[]; shorthandImports: ShorthandImport[]; @@ -110,6 +118,17 @@ export default class Component { slots: Set; javascript: string; + used: { + components: Set; + helpers: Set; + events: Set; + animations: Set; + transitions: Set; + actions: Set; + }; + + refCallees: Node[]; + code: MagicString; bindingGroups: string[]; @@ -128,16 +147,19 @@ export default class Component { aliases: Map; usedNames: Set; + locator: (search: number, startIndex?: number) => { + line: number, + column: number + }; + constructor( ast: Ast, source: string, name: string, - stylesheet: Stylesheet, options: CompileOptions, stats: Stats, target: DomTarget | SsrTarget ) { - stats.start('compile'); this.stats = stats; this.ast = ast; @@ -157,6 +179,17 @@ export default class Component { 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.refCallees = []; + this.bindingGroups = []; this.indirectDependencies = new Map(); @@ -173,7 +206,7 @@ export default class Component { this.usesRefs = false; // styles - this.stylesheet = stylesheet; + this.stylesheet = new Stylesheet(source, ast, this.file, options.dev); // allow compiler to deconflict user's `import { get } from 'whatever'` and // Svelte's builtin `import { get, ... } from 'svelte/shared.ts'`; @@ -186,6 +219,7 @@ export default class Component { this.computations = []; this.templateProperties = {}; + this.properties = new Map(); this.walkJs(); this.name = this.alias(name); @@ -207,7 +241,7 @@ export default class Component { // this.walkTemplate(); if (!this.customElement) this.stylesheet.reify(); - stylesheet.warnOnUnusedSelectors(options.onwarn); + this.stylesheet.warnOnUnusedSelectors(options.onwarn); } addSourcemapLocations(node: Node) { @@ -382,8 +416,6 @@ export default class Component { }) }; - this.stats.stop('compile'); - return { ast: this.ast, js, @@ -430,298 +462,425 @@ export default class Component { }; } - walkJs() { - const { - code, - source, - computations, - templateProperties, - imports - } = this; + validate() { + const { filename } = this.options; - const { js } = this.ast; + try { + if (this.stylesheet) { + this.stylesheet.validate(this); + } + } catch (err) { + if (onerror) { + onerror(err); + } else { + throw err; + } + } + } - const componentDefinition = new CodeBuilder(); + 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 + }); + } - if (js) { - this.addSourcemapLocations(js.content); + warn( + pos: { + start: number, + end: number + }, + warning: { + code: string, + message: string + } + ) { + if (!this.locator) { + this.locator = getLocator(this.source, { offsetLine: 1 }); + } - const indentation = detectIndentation(source.slice(js.start, js.end)); - const indentationLevel = getIndentationLevel(source, js.content.body[0].start); - const indentExclusionRanges = getIndentExclusionRanges(js.content); + const start = this.locator(pos.start); + const end = this.locator(pos.end); - const { scope, globals } = annotateWithScopes(js.content); + const frame = getCodeFrame(this.source, start.line - 1, start.column); - scope.declarations.forEach(name => { - this.userVars.add(name); - }); + 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}`, + }); + } - globals.forEach(name => { - this.userVars.add(name); + processDefaultExport(node, componentDefinition, 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` }); + } - const body = js.content.body.slice(); // slice, because we're going to be mutating the original + checkForComputedKeys(this, node.declaration.properties); + checkForDupes(this, node.declaration.properties); - // 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); + const props = this.properties; - node.specifiers.forEach((specifier: Node) => { - this.userVars.add(specifier.local.name); + 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}'` }); } } + }); - const defaultExport = this.defaultExport = body.find( - (node: Node) => node.type === 'ExportDefaultDeclaration' - ); + if (props.has('namespace')) { + const ns = nodeToString(props.get('namespace').value); + this.namespace = namespaces[ns] || ns; + } - if (defaultExport) { - defaultExport.declaration.properties.forEach((prop: Node) => { - templateProperties[getName(prop.key)] = prop; - }); + 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)); - }); - } + ['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; + 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}✂]; } + `); + } + }; - let deconflicted = key; - if (conflicts) while (deconflicted in conflicts) deconflicted += '_' + const addFunctionExpression = (name: string, node: Node) => { + const { async } = node; + const fnKeyword = async ? 'async function' : 'function'; - let name = this.getUniqueName(deconflicted); - this.templateVars.set(qualified, name); + let c = node.start; + while (this.source[c] !== '(') c += 1; + componentDefinition.addBlock(deindent` + ${fnKeyword} ${name}[✂${c}-${node.end}✂]; + `); + }; - if (allowShorthandImport && node.type === 'Literal' && typeof node.value === 'string') { - this.shorthandImports.push({ name, source: node.value }); - return; - } + const addValue = (name: string, node: Node) => { + componentDefinition.addBlock(deindent` + var ${name} = [✂${node.start}-${node.end}✂]; + `); + }; - // deindent - const indentationLevel = getIndentationLevel(source, node.start); - if (indentationLevel) { - removeIndentation(code, node.start, node.end, indentationLevel, indentExclusionRanges); - } + 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; + } - if (node.type === 'ArrowFunctionExpression') { - addArrowFunctionExpression(name, node); - } else if (node.type === 'FunctionExpression') { - addFunctionExpression(name, node); - } else { - addValue(name, node); - } - }; + 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(); - if (templateProperties.components) { - templateProperties.components.value.properties.forEach((property: Node) => { - addDeclaration(getName(property.key), property.value, true, 'components'); + 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); } + }); - if (templateProperties.computed) { - const dependencies = new Map(); + const visited = new Set(); - const fullStateComputations = []; + const visit = (key: string) => { + if (!dependencies.has(key)) return; // not a computation - templateProperties.computed.value.properties.forEach((prop: Node) => { - const key = getName(prop.key); - const value = prop.value; + if (visited.has(key)) return; + visited.add(key); - addDeclaration(key, value, false, 'computed', { - state: true, - changed: true - }); + const deps = dependencies.get(key); + deps.forEach(visit); - const param = value.params[0]; + computations.push({ key, deps, hasRestParam: false }); - const hasRestParam = ( - param.properties && - param.properties.some(prop => prop.type === 'RestElement') - ); + const prop = templateProperties.computed.value.properties.find((prop: Node) => getName(prop.key) === key); + }; - if (param.type !== 'ObjectPattern' || hasRestParam) { - fullStateComputations.push({ key, deps: null, hasRestParam }); - } else { - const deps = param.properties.map(prop => prop.key.name); + templateProperties.computed.value.properties.forEach((prop: Node) => + visit(getName(prop.key)) + ); - deps.forEach(dep => { - this.expectedProperties.add(dep); - }); - dependencies.set(key, deps); - } - }); + if (fullStateComputations.length > 0) { + computations.push(...fullStateComputations); + } + } - const visited = new Set(); + if (templateProperties.data) { + addDeclaration('data', templateProperties.data.value); + } - const visit = (key: string) => { - if (!dependencies.has(key)) return; // not a computation + if (templateProperties.events) { + templateProperties.events.value.properties.forEach((property: Node) => { + addDeclaration(getName(property.key), property.value, false, 'events'); + }); + } - if (visited.has(key)) return; - visited.add(key); + if (templateProperties.helpers) { + templateProperties.helpers.value.properties.forEach((property: Node) => { + addDeclaration(getName(property.key), property.value, false, 'helpers'); + }); + } - const deps = dependencies.get(key); - deps.forEach(visit); + if (templateProperties.methods) { + addDeclaration('methods', templateProperties.methods.value); - computations.push({ key, deps, hasRestParam: false }); + templateProperties.methods.value.properties.forEach(prop => { + this.methods.add(prop.key.name); + }); + } - const prop = templateProperties.computed.value.properties.find((prop: Node) => getName(prop.key) === key); - }; + if (templateProperties.namespace) { + const ns = nodeToString(templateProperties.namespace.value); + this.namespace = namespaces[ns] || ns; + } - templateProperties.computed.value.properties.forEach((prop: Node) => - visit(getName(prop.key)) - ); + if (templateProperties.oncreate) { + addDeclaration('oncreate', templateProperties.oncreate.value); + } - if (fullStateComputations.length > 0) { - computations.push(...fullStateComputations); - } - } + if (templateProperties.ondestroy) { + addDeclaration('ondestroy', templateProperties.ondestroy.value); + } - if (templateProperties.data) { - addDeclaration('data', templateProperties.data.value); - } + if (templateProperties.onstate) { + addDeclaration('onstate', templateProperties.onstate.value); + } - if (templateProperties.events) { - templateProperties.events.value.properties.forEach((property: Node) => { - addDeclaration(getName(property.key), property.value, false, 'events'); - }); - } + if (templateProperties.onupdate) { + addDeclaration('onupdate', templateProperties.onupdate.value); + } - if (templateProperties.helpers) { - templateProperties.helpers.value.properties.forEach((property: Node) => { - addDeclaration(getName(property.key), property.value, false, 'helpers'); - }); - } + if (templateProperties.preload) { + addDeclaration('preload', templateProperties.preload.value); + } - if (templateProperties.methods) { - addDeclaration('methods', templateProperties.methods.value); + if (templateProperties.props) { + this.props = templateProperties.props.value.elements.map((element: Node) => nodeToString(element)); + } - templateProperties.methods.value.properties.forEach(prop => { - this.methods.add(prop.key.name); - }); - } + if (templateProperties.setup) { + addDeclaration('setup', templateProperties.setup.value); + } - if (templateProperties.namespace) { - const ns = nodeToString(templateProperties.namespace.value); - this.namespace = namespaces[ns] || ns; - } + if (templateProperties.store) { + addDeclaration('store', templateProperties.store.value); + } - if (templateProperties.oncreate) { - addDeclaration('oncreate', templateProperties.oncreate.value); - } + if (templateProperties.tag) { + this.tag = nodeToString(templateProperties.tag.value); + } - if (templateProperties.ondestroy) { - addDeclaration('ondestroy', templateProperties.ondestroy.value); - } + if (templateProperties.transitions) { + templateProperties.transitions.value.properties.forEach((property: Node) => { + addDeclaration(getName(property.key), property.value, false, 'transitions'); + }); + } - if (templateProperties.onstate) { - addDeclaration('onstate', templateProperties.onstate.value); - } + if (templateProperties.animations) { + templateProperties.animations.value.properties.forEach((property: Node) => { + addDeclaration(getName(property.key), property.value, false, 'animations'); + }); + } - if (templateProperties.onupdate) { - addDeclaration('onupdate', templateProperties.onupdate.value); - } + if (templateProperties.actions) { + templateProperties.actions.value.properties.forEach((property: Node) => { + addDeclaration(getName(property.key), property.value, false, 'actions'); + }); + } - if (templateProperties.preload) { - addDeclaration('preload', templateProperties.preload.value); - } + this.defaultExport = node; + } - if (templateProperties.props) { - this.props = templateProperties.props.value.elements.map((element: Node) => nodeToString(element)); - } + walkJs() { + const { + code, + source, + imports + } = this; - if (templateProperties.setup) { - addDeclaration('setup', templateProperties.setup.value); - } + const { js } = this.ast; - if (templateProperties.store) { - addDeclaration('store', templateProperties.store.value); - } + const componentDefinition = new CodeBuilder(); - if (templateProperties.tag) { - this.tag = nodeToString(templateProperties.tag.value); - } + if (js) { + this.addSourcemapLocations(js.content); - if (templateProperties.transitions) { - templateProperties.transitions.value.properties.forEach((property: Node) => { - addDeclaration(getName(property.key), property.value, false, 'transitions'); + 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 + + 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 (templateProperties.animations) { - templateProperties.animations.value.properties.forEach((property: Node) => { - addDeclaration(getName(property.key), property.value, false, 'animations'); - }); + if (node.type === 'ExportDefaultDeclaration') { + this.processDefaultExport(node, componentDefinition, indentExclusionRanges); } - if (templateProperties.actions) { - templateProperties.actions.value.properties.forEach((property: Node) => { - addDeclaration(getName(property.key), property.value, false, 'actions'); + // 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 (defaultExport) { - removeIndentation(code, js.content.start, defaultExport.start, indentationLevel, indentExclusionRanges); - removeIndentation(code, defaultExport.end, js.content.end, indentationLevel, indentExclusionRanges); + 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); } @@ -733,11 +892,11 @@ export default class Component { let b = js.content.end; while (/\s/.test(source[b - 1])) b -= 1; - if (defaultExport) { + if (this.defaultExport) { this.javascript = ''; - if (a !== defaultExport.start) this.javascript += `[✂${a}-${defaultExport.start}✂]`; + if (a !== this.defaultExport.start) this.javascript += `[✂${a}-${this.defaultExport.start}✂]`; if (!componentDefinition.isEmpty()) this.javascript += componentDefinition; - if (defaultExport.end !== b) this.javascript += `[✂${defaultExport.end}-${b}✂]`; + if (this.defaultExport.end !== b) this.javascript += `[✂${this.defaultExport.end}-${b}✂]`; } else { this.javascript = a === b ? null : `[✂${a}-${b}✂]`; } diff --git a/src/compile/dom/index.ts b/src/compile/dom/index.ts index b51c05b2cc..4d05fc7049 100644 --- a/src/compile/dom/index.ts +++ b/src/compile/dom/index.ts @@ -27,22 +27,15 @@ 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 component = new Component(ast, source, options.name || 'SvelteComponent', stylesheet, options, stats, target); - const { computations, name, - templateProperties, - namespace, + templateProperties } = component; component.fragment.build(); @@ -61,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 => { @@ -113,7 +106,7 @@ export default function dom( `); } - target.blocks.forEach(block => { + component.target.blocks.forEach(block => { builder.addBlock(block.toString()); }); @@ -165,7 +158,7 @@ export default function dom( ${component.usesRefs && `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(component.expectedProperties).map(prop => { @@ -234,7 +227,7 @@ export default function dom( this._fragment.c();`} this._mount(options.target, options.anchor); - ${(component.hasComponents || target.hasComplexBindings || hasInitHooks || target.hasIntroTransitions) && + ${(component.hasComponents || component.target.hasComplexBindings || hasInitHooks || component.target.hasIntroTransitions) && `@flush(this);`} } `} @@ -277,7 +270,7 @@ export default function dom( this.set({ [attr]: newValue }); } - ${(component.hasComponents || target.hasComplexBindings || templateProperties.oncreate || target.hasIntroTransitions) && deindent` + ${(component.hasComponents || component.target.hasComplexBindings || templateProperties.oncreate || component.target.hasIntroTransitions) && deindent` connectedCallback() { @flush(this); } @@ -310,7 +303,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}'");` )} diff --git a/src/compile/index.ts b/src/compile/index.ts index cfb1b9e832..686ff9c724 100644 --- a/src/compile/index.ts +++ b/src/compile/index.ts @@ -1,11 +1,10 @@ import { assign } from '../shared'; import Stats from '../Stats'; import parse from '../parse/index'; -import Stylesheet from '../css/Stylesheet'; -import validate from '../validate'; -import generate from './dom/index'; -import generateSSR from './ssr/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); @@ -34,15 +33,37 @@ function default_onerror(error: Error) { throw error; } -export default function compile(source: string, _options: CompileOptions) { - const options = normalize_options(_options); - let ast: Ast; +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'); @@ -51,13 +72,18 @@ export default function compile(source: string, _options: CompileOptions) { return; } - stats.start('stylesheet'); - const stylesheet = new Stylesheet(source, ast, options.filename, options.dev); - stats.stop('stylesheet'); + stats.start('create component'); + const component = new Component( + ast, + source, + options.name || 'SvelteComponent', + options, + stats, - stats.start('validate'); - validate(ast, source, stylesheet, stats, options); - stats.stop('validate'); + // 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 }; @@ -65,5 +91,5 @@ export default function compile(source: string, _options: CompileOptions) { const compiler = options.generate === 'ssr' ? generateSSR : generate; - return compiler(ast, source, stylesheet, options, stats); + return compiler(component, options); } \ No newline at end of file diff --git a/src/compile/nodes/Window.ts b/src/compile/nodes/Window.ts index 998b3de9f0..ed3a29a371 100644 --- a/src/compile/nodes/Window.ts +++ b/src/compile/nodes/Window.ts @@ -1,14 +1,12 @@ -import CodeBuilder from '../../utils/CodeBuilder'; import deindent from '../../utils/deindent'; -import { stringify } from '../../utils/stringify'; -import flattenReference from '../../utils/flattenReference'; -import isVoidElementName from '../../utils/isVoidElementName'; -import validCalleeObjects from '../../utils/validCalleeObjects'; -import reservedNames from '../../utils/reservedNames'; import Node from './shared/Node'; import Block from '../dom/Block'; import Binding from './Binding'; import EventHandler from './EventHandler'; +import flattenReference from '../../utils/flattenReference'; +import fuzzymatch from '../../validate/utils/fuzzymatch'; +import list from '../../utils/list'; +import validateEventHandlerCallee from '../../validate/html/validateEventHandler'; const associatedEvents = { innerWidth: 'resize', @@ -33,6 +31,16 @@ const readonly = new Set([ 'online', ]); +const validBindings = [ + 'innerWidth', + 'innerHeight', + 'outerWidth', + 'outerHeight', + 'scrollX', + 'scrollY', + 'online' +]; + export default class Window extends Node { type: 'Window'; handlers: EventHandler[]; @@ -46,10 +54,50 @@ export default class Window extends Node { info.attributes.forEach(node => { if (node.type === 'EventHandler') { + component.used.events.add(node.name); + validateEventHandlerCallee(component, node); // TODO make this a method of component? + this.handlers.push(new EventHandler(component, this, scope, node)); - } else if (node.type === 'Binding') { + } + + else if (node.type === 'Binding') { + if (node.value.type !== 'Identifier') { + const { parts } = flattenReference(node.value); + + component.error(node.value, { + code: `invalid-binding`, + message: `Bindings on must be to top-level properties, e.g. '${parts[parts.length - 1]}' rather than '${parts.join('.')}'` + }); + } + + if (!~validBindings.indexOf(node.name)) { + const match = node.name === 'width' + ? 'innerWidth' + : node.name === 'height' + ? 'innerHeight' + : fuzzymatch(node.name, validBindings); + + const message = `'${node.name}' is not a valid binding on `; + + if (match) { + component.error(node, { + code: `invalid-binding`, + message: `${message} (did you mean '${match}'?)` + }); + } else { + component.error(node, { + code: `invalid-binding`, + message: `${message} — valid bindings are ${list(validBindings)}` + }); + } + } + this.bindings.push(new Binding(component, this, scope, node)); } + + else { + // TODO there shouldn't be anything else here... + } }); } diff --git a/src/compile/nodes/shared/Node.ts b/src/compile/nodes/shared/Node.ts index c9741803c6..7f5a70ccbb 100644 --- a/src/compile/nodes/shared/Node.ts +++ b/src/compile/nodes/shared/Node.ts @@ -169,4 +169,8 @@ export default class Node { remount(name: string) { return `${this.var}.m(${name}._slotted.default, null);`; } + + validate() { + throw new Error(`${this.type} does not implement validate method`); + } } \ No newline at end of file diff --git a/src/compile/ssr/index.ts b/src/compile/ssr/index.ts index 133c82617d..5e9cdadf7b 100644 --- a/src/compile/ssr/index.ts +++ b/src/compile/ssr/index.ts @@ -32,17 +32,11 @@ export class SsrTarget { } export default function ssr( - ast: Ast, - source: string, - stylesheet: Stylesheet, - options: CompileOptions, - stats: Stats + component: Component, + options: CompileOptions ) { const format = options.format || 'cjs'; - const target = new SsrTarget(); - const component = new Component(ast, source, options.name || 'SvelteComponent', stylesheet, options, stats, target); - const { computations, name, templateProperties } = component; // create main render() function @@ -123,7 +117,7 @@ export default function ssr( ({ key }) => `ctx.${key} = %computed-${key}(ctx);` )} - ${target.bindings.length && + ${component.target.bindings.length && deindent` var settled = false; var tmp; @@ -131,11 +125,11 @@ export default function ssr( while (!settled) { settled = true; - ${target.bindings.join('\n\n')} + ${component.target.bindings.join('\n\n')} } `} - return \`${target.renderCode}\`; + return \`${component.target.renderCode}\`; }; ${name}.css = { diff --git a/src/validate/html/validateEventHandler.ts b/src/validate/html/validateEventHandler.ts index df499a9fd5..c1e5a6808c 100644 --- a/src/validate/html/validateEventHandler.ts +++ b/src/validate/html/validateEventHandler.ts @@ -3,20 +3,20 @@ import list from '../../utils/list'; import validate, { Validator } from '../index'; import validCalleeObjects from '../../utils/validCalleeObjects'; import { Node } from '../../interfaces'; +import Component from '../../compile/Component'; const validBuiltins = new Set(['set', 'fire', 'destroy']); export default function validateEventHandlerCallee( - validator: Validator, - attribute: Node, - refCallees: Node[] + component: Component, + attribute: Node ) { if (!attribute.expression) return; const { callee, type } = attribute.expression; if (type !== 'CallExpression') { - validator.error(attribute.expression, { + component.error(attribute.expression, { code: `invalid-event-handler`, message: `Expected a call expression` }); @@ -27,13 +27,13 @@ export default function validateEventHandlerCallee( if (validCalleeObjects.has(name) || name === 'options') return; if (name === 'refs') { - refCallees.push(callee); + component.refCallees.push(callee); return; } if ( (callee.type === 'Identifier' && validBuiltins.has(callee.name)) || - validator.methods.has(callee.name) + component.methods.has(callee.name) ) { return; } @@ -45,22 +45,22 @@ export default function validateEventHandlerCallee( const validCallees = ['this.*', 'refs.*', 'event.*', 'options.*', 'console.*'].concat( Array.from(validBuiltins), - Array.from(validator.methods.keys()) + Array.from(component.methods.keys()) ); - let message = `'${validator.source.slice(callee.start, callee.end)}' is an invalid callee ` ; + let message = `'${component.source.slice(callee.start, callee.end)}' is an invalid callee ` ; if (name === 'store') { - message += `(did you mean '$${validator.source.slice(callee.start + 6, callee.end)}(...)'?)`; + message += `(did you mean '$${component.source.slice(callee.start + 6, callee.end)}(...)'?)`; } else { message += `(should be one of ${list(validCallees)})`; - if (callee.type === 'Identifier' && validator.helpers.has(callee.name)) { + if (callee.type === 'Identifier' && component.helpers.has(callee.name)) { message += `. '${callee.name}' exists on 'helpers', did you put it in the wrong place?`; } } - validator.warn(attribute.expression, { + component.warn(attribute.expression, { code: `invalid-callee`, message }); diff --git a/src/validate/html/validateWindow.ts b/src/validate/html/validateWindow.ts index 6f312e0407..7d46c40a1d 100644 --- a/src/validate/html/validateWindow.ts +++ b/src/validate/html/validateWindow.ts @@ -15,7 +15,12 @@ const validBindings = [ 'online' ]; -export default function validateWindow(validator: Validator, node: Node, refs: Map, refCallees: Node[]) { +export default function validateWindow( + validator: Validator, + node: Node, + refs: Map, + refCallees: Node[] +) { node.attributes.forEach((attribute: Node) => { if (attribute.type === 'Binding') { if (attribute.value.type !== 'Identifier') {