Skip to content

Commit

Permalink
basic custom element generation (#797)
Browse files Browse the repository at this point in the history
  • Loading branch information
Rich-Harris committed Sep 2, 2017
1 parent 89c0b71 commit afe3e2e
Show file tree
Hide file tree
Showing 11 changed files with 439 additions and 76 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 28 additions & 1 deletion src/generators/Generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -31,6 +31,10 @@ export default class Generator {
name: string;
options: CompileOptions;

customElement: CustomElementOptions;
tag: string;
props: string[];

defaultExport: Node[];
imports: Node[];
helpers: Set<string>;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
168 changes: 95 additions & 73 deletions src/generators/dom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface CompileOptions {
cascade?: boolean;
hydratable?: boolean;
legacy?: boolean;
customElement: CustomElementOptions | true;

onerror?: (error: Error) => void;
onwarn?: (warning: Warning) => void;
Expand All @@ -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[];
}
2 changes: 2 additions & 0 deletions src/validate/js/propValidators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -24,6 +25,7 @@ export default {
components,
events,
namespace,
tag,
transitions,
setup,
};
20 changes: 20 additions & 0 deletions src/validate/js/propValidators/tag.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
}
5 changes: 5 additions & 0 deletions test/js/samples/custom-element-basic/_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
options: {
customElement: true
}
};

0 comments on commit afe3e2e

Please sign in to comment.