From afe3e2e669f0c4d758d02f06795ccaf2c4bee986 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 2 Sep 2017 16:29:02 -0400 Subject: [PATCH] basic custom element generation (#797) --- package.json | 1 + src/generators/Generator.ts | 29 ++- src/generators/dom/index.ts | 168 ++++++++------ src/interfaces.ts | 6 + src/validate/js/propValidators/index.ts | 2 + src/validate/js/propValidators/tag.ts | 20 ++ .../samples/custom-element-basic/_config.js | 5 + .../custom-element-basic/expected-bundle.js | 218 ++++++++++++++++++ .../samples/custom-element-basic/expected.js | 57 +++++ .../samples/custom-element-basic/input.html | 7 + test/runtime/index.js | 2 - 11 files changed, 439 insertions(+), 76 deletions(-) create mode 100644 src/validate/js/propValidators/tag.ts create mode 100644 test/js/samples/custom-element-basic/_config.js create mode 100644 test/js/samples/custom-element-basic/expected-bundle.js create mode 100644 test/js/samples/custom-element-basic/expected.js create mode 100644 test/js/samples/custom-element-basic/input.html diff --git a/package.json b/package.json index c9846fa508..a86cc22e26 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ ], "scripts": { "test": "mocha --opts mocha.opts", + "quicktest": "mocha --opts mocha.opts", "precoverage": "export COVERAGE=true && nyc mocha --opts mocha.coverage.opts", "coverage": "nyc report --reporter=text-lcov > coverage.lcov", "codecov": "codecov", diff --git a/src/generators/Generator.ts b/src/generators/Generator.ts index ef21649a47..f6010a6781 100644 --- a/src/generators/Generator.ts +++ b/src/generators/Generator.ts @@ -15,7 +15,7 @@ import clone from '../utils/clone'; import DomBlock from './dom/Block'; import SsrBlock from './server-side-rendering/Block'; import Stylesheet from '../css/Stylesheet'; -import { Node, GenerateOptions, Parsed, CompileOptions } from '../interfaces'; +import { Node, GenerateOptions, Parsed, CompileOptions, CustomElementOptions } from '../interfaces'; const test = typeof global !== 'undefined' && global.__svelte_test; @@ -31,6 +31,10 @@ export default class Generator { name: string; options: CompileOptions; + customElement: CustomElementOptions; + tag: string; + props: string[]; + defaultExport: Node[]; imports: Node[]; helpers: Set; @@ -100,6 +104,19 @@ export default class Generator { this.parseJs(); this.name = this.alias(name); + + if (options.customElement === true) { + this.customElement = { + tag: this.tag, + props: this.props // TODO autofill this in + } + } else { + this.customElement = options.customElement; + } + + if (this.customElement && !this.customElement.tag) { + throw new Error(`No tag name specified`); // TODO better error + } } addSourcemapLocations(node: Node) { @@ -554,6 +571,16 @@ export default class Generator { templateProperties.ondestroy = templateProperties.onteardown; } + if (templateProperties.tag) { + this.tag = templateProperties.tag.value.value; + removeObjectKey(this.code, defaultExport.declaration, 'tag'); + } + + if (templateProperties.props) { + // TODO + this.props = templateProperties.props.value; + } + // now that we've analysed the default export, we can determine whether or not we need to keep it let hasDefaultExport = !!defaultExport; if (defaultExport && defaultExport.declaration.properties.length === 0) { diff --git a/src/generators/dom/index.ts b/src/generators/dom/index.ts index 6003c57c8f..d3adee900a 100644 --- a/src/generators/dom/index.ts +++ b/src/generators/dom/index.ts @@ -148,88 +148,110 @@ export default function dom( .join(',\n')} }`; - // TODO deprecate component.teardown() - builder.addBlock(deindent` - function ${name} ( options ) { - ${options.dev && - `if ( !options || (!options.target && !options._root) ) throw new Error( "'target' is a required option" );`} - this.options = options; - ${generator.usesRefs && `this.refs = {};`} - this._state = ${templateProperties.data - ? `@assign( @template.data(), options.data )` - : `options.data || {}`}; - ${generator.metaBindings} - ${computations.length && `this._recompute( {}, this._state, {}, true );`} - ${options.dev && - Array.from(generator.expectedProperties).map( - prop => - `if ( !( '${prop}' in this._state ) ) console.warn( "Component was created without expected data property '${prop}'" );` - )} - ${generator.bindingGroups.length && - `this._bindingGroups = [ ${Array(generator.bindingGroups.length) - .fill('[]') - .join(', ')} ];`} - - this._observers = { - pre: Object.create( null ), - post: Object.create( null ) - }; - - this._handlers = Object.create( null ); - ${templateProperties.ondestroy && `this._handlers.destroy = [@template.ondestroy]`} - - this._root = options._root || this; - this._yield = options._yield; - this._bind = options._bind; - ${generator.slots.size && `this._slotted = options.slots || {};`} + const target = generator.customElement ? `this.attachShadow({ mode: 'open' })` : `options.target`; + const anchor = generator.customElement ? `null` : `options.anchor || null`; + + const constructorBody = deindent` + ${options.dev && + `if ( !options || (!options.target && !options._root) ) throw new Error( "'target' is a required option" );`} + this.options = options; + ${generator.usesRefs && `this.refs = {};`} + this._state = ${templateProperties.data + ? `@assign( @template.data(), options.data )` + : `options.data || {}`}; + ${generator.metaBindings} + ${computations.length && `this._recompute( {}, this._state, {}, true );`} + ${options.dev && + Array.from(generator.expectedProperties).map( + prop => + `if ( !( '${prop}' in this._state ) ) console.warn( "Component was created without expected data property '${prop}'" );` + )} + ${generator.bindingGroups.length && + `this._bindingGroups = [ ${Array(generator.bindingGroups.length) + .fill('[]') + .join(', ')} ];`} + + this._observers = { + pre: Object.create( null ), + post: Object.create( null ) + }; + + this._handlers = Object.create( null ); + ${templateProperties.ondestroy && `this._handlers.destroy = [@template.ondestroy]`} + + this._root = options._root || this; + this._yield = options._yield; + this._bind = options._bind; + ${generator.slots.size && `this._slotted = options.slots || {};`} + + ${generator.stylesheet.hasStyles && + options.css !== false && + `if ( !document.getElementById( '${generator.stylesheet.id}-style' ) ) @add_css();`} + + ${templateProperties.oncreate && `var oncreate = @template.oncreate.bind( this );`} + + ${(templateProperties.oncreate || generator.hasComponents || generator.hasComplexBindings || generator.hasIntroTransitions) && deindent` + if ( !options._root ) { + this._oncreate = [${templateProperties.oncreate && `oncreate`}]; + ${(generator.hasComponents || generator.hasComplexBindings) && `this._beforecreate = [];`} + ${(generator.hasComponents || generator.hasIntroTransitions) && `this._aftercreate = [];`} + } ${templateProperties.oncreate && deindent` + else { + this._root._oncreate.push(oncreate); + } + `} + `} - ${generator.stylesheet.hasStyles && - options.css !== false && - `if ( !document.getElementById( '${generator.stylesheet.id}-style' ) ) @add_css();`} + ${generator.slots.size && `this.slots = {};`} - ${templateProperties.oncreate && `var oncreate = @template.oncreate.bind( this );`} + this._fragment = @create_main_fragment( this._state, this ); - ${(templateProperties.oncreate || generator.hasComponents || generator.hasComplexBindings || generator.hasIntroTransitions) && deindent` - if ( !options._root ) { - this._oncreate = [${templateProperties.oncreate && `oncreate`}]; - ${(generator.hasComponents || generator.hasComplexBindings) && `this._beforecreate = [];`} - ${(generator.hasComponents || generator.hasIntroTransitions) && `this._aftercreate = [];`} - } ${templateProperties.oncreate && deindent` - else { - this._root._oncreate.push(oncreate); - } + if ( !options._root ) { + ${generator.hydratable + ? deindent` + var nodes = @children( options.target ); + options.hydrate ? this._fragment.claim( nodes ) : this._fragment.create(); + nodes.forEach( @detachNode ); + ` : + deindent` + ${options.dev && `if ( options.hydrate ) throw new Error( 'options.hydrate only works if the component was compiled with the \`hydratable: true\` option' );`} + this._fragment.create(); `} - `} + this._fragment.${block.hasIntroMethod ? 'intro' : 'mount'}( ${target}, ${anchor} ); + } - ${generator.slots.size && `this.slots = {};`} - - this._fragment = @create_main_fragment( this._state, this ); - - if ( options.target ) { - ${generator.hydratable - ? deindent` - var nodes = @children( options.target ); - options.hydrate ? this._fragment.claim( nodes ) : this._fragment.create(); - nodes.forEach( @detachNode ); - ` : - deindent` - ${options.dev && `if ( options.hydrate ) throw new Error( 'options.hydrate only works if the component was compiled with the \`hydratable: true\` option' );`} - this._fragment.create(); - `} - this._fragment.${block.hasIntroMethod ? 'intro' : 'mount'}( options.target, options.anchor || null ); + ${(generator.hasComponents || generator.hasComplexBindings || templateProperties.oncreate || generator.hasIntroTransitions) && deindent` + if ( !options._root ) { + ${generator.hasComponents && `this._lock = true;`} + ${(generator.hasComponents || generator.hasComplexBindings) && `@callAll(this._beforecreate);`} + ${(generator.hasComponents || templateProperties.oncreate) && `@callAll(this._oncreate);`} + ${(generator.hasComponents || generator.hasIntroTransitions) && `@callAll(this._aftercreate);`} + ${generator.hasComponents && `this._lock = false;`} } + `} + `; - ${(generator.hasComponents || generator.hasComplexBindings || templateProperties.oncreate || generator.hasIntroTransitions) && deindent` - if ( !options._root ) { - ${generator.hasComponents && `this._lock = true;`} - ${(generator.hasComponents || generator.hasComplexBindings) && `@callAll(this._beforecreate);`} - ${(generator.hasComponents || templateProperties.oncreate) && `@callAll(this._oncreate);`} - ${(generator.hasComponents || generator.hasIntroTransitions) && `@callAll(this._aftercreate);`} - ${generator.hasComponents && `this._lock = false;`} + if (generator.customElement) { + builder.addBlock(deindent` + class ${name} extends HTMLElement { + constructor(options = {}) { + super(); + ${constructorBody} } - `} - } + } + customElements.define('${generator.tag}', ${name}); + `); + } else { + builder.addBlock(deindent` + function ${name} ( options ) { + ${constructorBody} + } + `); + } + + // TODO deprecate component.teardown() + builder.addBlock(deindent` @assign( ${prototypeBase}, ${proto}); ${options.dev && deindent` diff --git a/src/interfaces.ts b/src/interfaces.ts index 05b65375f3..07bca42ca0 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -54,6 +54,7 @@ export interface CompileOptions { cascade?: boolean; hydratable?: boolean; legacy?: boolean; + customElement: CustomElementOptions | true; onerror?: (error: Error) => void; onwarn?: (warning: Warning) => void; @@ -67,4 +68,9 @@ export interface GenerateOptions { export interface Visitor { enter: (node: Node) => void; leave?: (node: Node) => void; +} + +export interface CustomElementOptions { + tag?: string; + props?: string[]; } \ No newline at end of file diff --git a/src/validate/js/propValidators/index.ts b/src/validate/js/propValidators/index.ts index aa71fa0aeb..1be9f9ffbb 100644 --- a/src/validate/js/propValidators/index.ts +++ b/src/validate/js/propValidators/index.ts @@ -9,6 +9,7 @@ import methods from './methods'; import components from './components'; import events from './events'; import namespace from './namespace'; +import tag from './tag'; import transitions from './transitions'; import setup from './setup'; @@ -24,6 +25,7 @@ export default { components, events, namespace, + tag, transitions, setup, }; diff --git a/src/validate/js/propValidators/tag.ts b/src/validate/js/propValidators/tag.ts new file mode 100644 index 0000000000..2bd55b894a --- /dev/null +++ b/src/validate/js/propValidators/tag.ts @@ -0,0 +1,20 @@ +import usesThisOrArguments from '../utils/usesThisOrArguments'; +import { Validator } from '../../'; +import { Node } from '../../../interfaces'; + +export default function tag(validator: Validator, prop: Node) { + if (prop.value.type !== 'Literal' || typeof prop.value.value !== 'string') { + validator.error( + `'tag' must be a string literal`, + prop.value.start + ); + } + + const tag = prop.value.value; + if (!/^[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]+$/.test(tag)) { + validator.error( + `tag name must be two or more words joined by the '-' character`, + prop.value.start + ); + } +} diff --git a/test/js/samples/custom-element-basic/_config.js b/test/js/samples/custom-element-basic/_config.js new file mode 100644 index 0000000000..735dd07e62 --- /dev/null +++ b/test/js/samples/custom-element-basic/_config.js @@ -0,0 +1,5 @@ +export default { + options: { + customElement: true + } +}; \ No newline at end of file diff --git a/test/js/samples/custom-element-basic/expected-bundle.js b/test/js/samples/custom-element-basic/expected-bundle.js new file mode 100644 index 0000000000..aff7954c60 --- /dev/null +++ b/test/js/samples/custom-element-basic/expected-bundle.js @@ -0,0 +1,218 @@ +function noop() {} + +function assign(target) { + var k, + source, + i = 1, + len = arguments.length; + for (; i < len; i++) { + source = arguments[i]; + for (k in source) target[k] = source[k]; + } + + return target; +} + +function appendNode(node, target) { + target.appendChild(node); +} + +function insertNode(node, target, anchor) { + target.insertBefore(node, anchor); +} + +function detachNode(node) { + node.parentNode.removeChild(node); +} + +function createElement(name) { + return document.createElement(name); +} + +function createText(data) { + return document.createTextNode(data); +} + +function destroy(detach) { + this.destroy = noop; + this.fire('destroy'); + this.set = this.get = noop; + + if (detach !== false) this._fragment.unmount(); + this._fragment.destroy(); + this._fragment = this._state = null; +} + +function differs(a, b) { + return a !== b || ((a && typeof a === 'object') || typeof a === 'function'); +} + +function dispatchObservers(component, group, changed, newState, oldState) { + for (var key in group) { + if (!changed[key]) continue; + + var newValue = newState[key]; + var oldValue = oldState[key]; + + var callbacks = group[key]; + if (!callbacks) continue; + + for (var i = 0; i < callbacks.length; i += 1) { + var callback = callbacks[i]; + if (callback.__calling) continue; + + callback.__calling = true; + callback.call(component, newValue, oldValue); + callback.__calling = false; + } + } +} + +function get(key) { + return key ? this._state[key] : this._state; +} + +function fire(eventName, data) { + var handlers = + eventName in this._handlers && this._handlers[eventName].slice(); + if (!handlers) return; + + for (var i = 0; i < handlers.length; i += 1) { + handlers[i].call(this, data); + } +} + +function observe(key, callback, options) { + var group = options && options.defer + ? this._observers.post + : this._observers.pre; + + (group[key] || (group[key] = [])).push(callback); + + if (!options || options.init !== false) { + callback.__calling = true; + callback.call(this, this._state[key]); + callback.__calling = false; + } + + return { + cancel: function() { + var index = group[key].indexOf(callback); + if (~index) group[key].splice(index, 1); + } + }; +} + +function on(eventName, handler) { + if (eventName === 'teardown') return this.on('destroy', handler); + + var handlers = this._handlers[eventName] || (this._handlers[eventName] = []); + handlers.push(handler); + + return { + cancel: function() { + var index = handlers.indexOf(handler); + if (~index) handlers.splice(index, 1); + } + }; +} + +function set(newState) { + this._set(assign({}, newState)); + if (this._root._lock) return; + this._root._lock = true; + callAll(this._root._beforecreate); + callAll(this._root._oncreate); + callAll(this._root._aftercreate); + this._root._lock = false; +} + +function _set(newState) { + var oldState = this._state, + changed = {}, + dirty = false; + + for (var key in newState) { + if (differs(newState[key], oldState[key])) changed[key] = dirty = true; + } + if (!dirty) return; + + this._state = assign({}, oldState, newState); + this._recompute(changed, this._state, oldState, false); + if (this._bind) this._bind(changed, this._state); + dispatchObservers(this, this._observers.pre, changed, this._state, oldState); + this._fragment.update(changed, this._state); + dispatchObservers(this, this._observers.post, changed, this._state, oldState); +} + +function callAll(fns) { + while (fns && fns.length) fns.pop()(); +} + +var proto = { + destroy: destroy, + get: get, + fire: fire, + observe: observe, + on: on, + set: set, + teardown: destroy, + _recompute: noop, + _set: _set +}; + +function create_main_fragment ( state, component ) { + var div, text; + + return { + create: function () { + div = createElement( 'div' ); + text = createText( "I am shadow DOM" ); + }, + + mount: function ( target, anchor ) { + insertNode( div, target, anchor ); + appendNode( text, div ); + }, + + update: noop, + + unmount: function () { + detachNode( div ); + }, + + destroy: noop + }; +} + +class SvelteComponent extends HTMLElement { + constructor(options = {}) { + super(); + this.options = options; + this._state = options.data || {}; + + this._observers = { + pre: Object.create( null ), + post: Object.create( null ) + }; + + this._handlers = Object.create( null ); + + this._root = options._root || this; + this._yield = options._yield; + this._bind = options._bind; + + this._fragment = create_main_fragment( this._state, this ); + + if ( !options._root ) { + this._fragment.create(); + this._fragment.mount( this.attachShadow({ mode: 'open' }), null ); + } + } +} + +customElements.define('custom-element', SvelteComponent); + +assign( SvelteComponent.prototype, proto ); + +export default SvelteComponent; diff --git a/test/js/samples/custom-element-basic/expected.js b/test/js/samples/custom-element-basic/expected.js new file mode 100644 index 0000000000..50023bb4ec --- /dev/null +++ b/test/js/samples/custom-element-basic/expected.js @@ -0,0 +1,57 @@ +import { appendNode, assign, createElement, createText, detachNode, insertNode, noop, proto } from "svelte/shared.js"; + +function create_main_fragment ( state, component ) { + var div, text; + + return { + create: function () { + div = createElement( 'div' ); + text = createText( "I am shadow DOM" ); + }, + + mount: function ( target, anchor ) { + insertNode( div, target, anchor ); + appendNode( text, div ); + }, + + update: noop, + + unmount: function () { + detachNode( div ); + }, + + destroy: noop + }; +} + +class SvelteComponent extends HTMLElement { + constructor(options = {}) { + super(); + this.options = options; + this._state = options.data || {}; + + this._observers = { + pre: Object.create( null ), + post: Object.create( null ) + }; + + this._handlers = Object.create( null ); + + this._root = options._root || this; + this._yield = options._yield; + this._bind = options._bind; + + this._fragment = create_main_fragment( this._state, this ); + + if ( !options._root ) { + this._fragment.create(); + this._fragment.mount( this.attachShadow({ mode: 'open' }), null ); + } + } +} + +customElements.define('custom-element', SvelteComponent); + +assign( SvelteComponent.prototype, proto ); + +export default SvelteComponent; \ No newline at end of file diff --git a/test/js/samples/custom-element-basic/input.html b/test/js/samples/custom-element-basic/input.html new file mode 100644 index 0000000000..3e00e7121f --- /dev/null +++ b/test/js/samples/custom-element-basic/input.html @@ -0,0 +1,7 @@ +
I am shadow DOM
+ + \ No newline at end of file diff --git a/test/runtime/index.js b/test/runtime/index.js index 78415ac428..95950b6668 100644 --- a/test/runtime/index.js +++ b/test/runtime/index.js @@ -22,8 +22,6 @@ function getName(filename) { return base[0].toUpperCase() + base.slice(1); } -const Object_assign = Object.assign; - describe("runtime", () => { before(() => { svelte = loadSvelte(true);