diff --git a/packages/@ember/canary-features/index.ts b/packages/@ember/canary-features/index.ts index 477d3b996b6..76726056154 100644 --- a/packages/@ember/canary-features/index.ts +++ b/packages/@ember/canary-features/index.ts @@ -15,6 +15,7 @@ export const DEFAULT_FEATURES = { GLIMMER_CUSTOM_COMPONENT_MANAGER: null, EMBER_TEMPLATE_BLOCK_LET_HELPER: true, EMBER_METAL_TRACKED_PROPERTIES: null, + EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION: null, }; /** @@ -81,3 +82,6 @@ export const GLIMMER_CUSTOM_COMPONENT_MANAGER = featureValue( export const EMBER_TEMPLATE_BLOCK_LET_HELPER = featureValue( FEATURES.EMBER_TEMPLATE_BLOCK_LET_HELPER ); +export const EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION = featureValue( + FEATURES.EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION +); diff --git a/packages/ember-glimmer/tests/integration/components/angle-bracket-invocation-test.js b/packages/ember-glimmer/tests/integration/components/angle-bracket-invocation-test.js new file mode 100644 index 00000000000..65545636714 --- /dev/null +++ b/packages/ember-glimmer/tests/integration/components/angle-bracket-invocation-test.js @@ -0,0 +1,565 @@ +import { EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION } from '@ember/canary-features'; +import { moduleFor, RenderingTest } from '../../utils/test-case'; +import { set } from 'ember-metal'; +import { Component } from '../../utils/helpers'; +import { strip } from '../../utils/abstract-test-case'; +import { classes } from '../../utils/test-helpers'; + +if (EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION) { + moduleFor( + 'AngleBracket Invocation', + class extends RenderingTest { + '@test it can render a basic template only component'() { + this.registerComponent('foo-bar', { template: 'hello' }); + + this.render(''); + + this.assertComponentElement(this.firstChild, { content: 'hello' }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { content: 'hello' }); + } + + '@test it can render a basic component with template and javascript'() { + this.registerComponent('foo-bar', { + template: 'FIZZ BAR {{local}}', + ComponentClass: Component.extend({ local: 'hey' }), + }); + + this.render(''); + + this.assertComponentElement(this.firstChild, { content: 'FIZZ BAR hey' }); + } + + '@test it can render a single word component name'() { + this.registerComponent('foo', { template: 'hello' }); + + this.render(''); + + this.assertComponentElement(this.firstChild, { content: 'hello' }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { content: 'hello' }); + } + + '@test it can not render a component name without initial capital letter'(assert) { + this.registerComponent('div', { + ComponentClass: Component.extend({ + init() { + assert.ok(false, 'should not have created component'); + }, + }), + }); + + this.render('
'); + + this.assertElement(this.firstChild, { tagName: 'div', content: '' }); + } + + '@test it can have a custom id and it is not bound'() { + this.registerComponent('foo-bar', { template: '{{id}} {{elementId}}' }); + + this.render('', { + customId: 'bizz', + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bizz bizz', + }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bizz bizz', + }); + + this.runTask(() => set(this.context, 'customId', 'bar')); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bar bizz', + }); + + this.runTask(() => set(this.context, 'customId', 'bizz')); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { id: 'bizz' }, + content: 'bizz bizz', + }); + } + + '@test it can have a custom tagName'() { + let FooBarComponent = Component.extend({ + tagName: 'foo-bar', + }); + + this.registerComponent('foo-bar', { + ComponentClass: FooBarComponent, + template: 'hello', + }); + + this.render(''); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + } + + '@test it can have a custom tagName from the invocation'() { + this.registerComponent('foo-bar', { template: 'hello' }); + + this.render(''); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'foo-bar', + content: 'hello', + }); + } + + '@test it can have custom classNames'() { + let FooBarComponent = Component.extend({ + classNames: ['foo', 'bar'], + }); + + this.registerComponent('foo-bar', { + ComponentClass: FooBarComponent, + template: 'hello', + }); + + this.render(''); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view foo bar') }, + content: 'hello', + }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { class: classes('ember-view foo bar') }, + content: 'hello', + }); + } + + '@test class property on components can be dynamic'() { + this.registerComponent('foo-bar', { template: 'hello' }); + + this.render('', { + fooBar: true, + }); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view foo-bar') }, + }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view foo-bar') }, + }); + + this.runTask(() => set(this.context, 'fooBar', false)); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view') }, + }); + + this.runTask(() => set(this.context, 'fooBar', true)); + + this.assertComponentElement(this.firstChild, { + content: 'hello', + attrs: { class: classes('ember-view foo-bar') }, + }); + } + + '@test it can set custom classNames from the invocation'() { + let FooBarComponent = Component.extend({ + classNames: ['foo'], + }); + + this.registerComponent('foo-bar', { + ComponentClass: FooBarComponent, + template: 'hello', + }); + + this.render(strip` + + + + `); + + this.assertComponentElement(this.nthChild(0), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(1), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(2), { + tagName: 'div', + attrs: { class: classes('ember-view foo') }, + content: 'hello', + }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.nthChild(0), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(1), { + tagName: 'div', + attrs: { class: classes('ember-view foo bar baz') }, + content: 'hello', + }); + this.assertComponentElement(this.nthChild(2), { + tagName: 'div', + attrs: { class: classes('ember-view foo') }, + content: 'hello', + }); + } + + '@test it has an element'() { + let instance; + + let FooBarComponent = Component.extend({ + init() { + this._super(); + instance = this; + }, + }); + + this.registerComponent('foo-bar', { + ComponentClass: FooBarComponent, + template: 'hello', + }); + + this.render(''); + + let element1 = instance.element; + + this.assertComponentElement(element1, { content: 'hello' }); + + this.runTask(() => this.rerender()); + + let element2 = instance.element; + + this.assertComponentElement(element2, { content: 'hello' }); + + this.assertSameNode(element2, element1); + } + + '@test it has the right parentView and childViews'(assert) { + let fooBarInstance, fooBarBazInstance; + + let FooBarComponent = Component.extend({ + init() { + this._super(); + fooBarInstance = this; + }, + }); + + let FooBarBazComponent = Component.extend({ + init() { + this._super(); + fooBarBazInstance = this; + }, + }); + + this.registerComponent('foo-bar', { + ComponentClass: FooBarComponent, + template: 'foo-bar {{foo-bar-baz}}', + }); + this.registerComponent('foo-bar-baz', { + ComponentClass: FooBarBazComponent, + template: 'foo-bar-baz', + }); + + this.render(''); + this.assertText('foo-bar foo-bar-baz'); + + assert.equal(fooBarInstance.parentView, this.component); + assert.equal(fooBarBazInstance.parentView, fooBarInstance); + + assert.deepEqual(this.component.childViews, [fooBarInstance]); + assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); + + this.runTask(() => this.rerender()); + this.assertText('foo-bar foo-bar-baz'); + + assert.equal(fooBarInstance.parentView, this.component); + assert.equal(fooBarBazInstance.parentView, fooBarInstance); + + assert.deepEqual(this.component.childViews, [fooBarInstance]); + assert.deepEqual(fooBarInstance.childViews, [fooBarBazInstance]); + } + + '@test it renders passed named arguments'() { + this.registerComponent('foo-bar', { + template: '{{@foo}}', + }); + + this.render('', { + model: { + bar: 'Hola', + }, + }); + + this.assertText('Hola'); + + this.runTask(() => this.rerender()); + + this.assertText('Hola'); + + this.runTask(() => this.context.set('model.bar', 'Hello')); + + this.assertText('Hello'); + + this.runTask(() => this.context.set('model', { bar: 'Hola' })); + + this.assertText('Hola'); + } + + '@test it reflects named arguments as properties'() { + this.registerComponent('foo-bar', { + template: '{{foo}}', + }); + + this.render('', { + model: { + bar: 'Hola', + }, + }); + + this.assertText('Hola'); + + this.runTask(() => this.rerender()); + + this.assertText('Hola'); + + this.runTask(() => this.context.set('model.bar', 'Hello')); + + this.assertText('Hello'); + + this.runTask(() => this.context.set('model', { bar: 'Hola' })); + + this.assertText('Hola'); + } + + '@test it can render a basic component with a block'() { + this.registerComponent('foo-bar', { + template: '{{yield}} - In component', + }); + + this.render('hello'); + + this.assertComponentElement(this.firstChild, { + content: 'hello - In component', + }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + content: 'hello - In component', + }); + } + + '@test it can yield internal and external properties positionally'() { + let instance; + + let FooBarComponent = Component.extend({ + init() { + this._super(...arguments); + instance = this; + }, + greeting: 'hello', + }); + + this.registerComponent('foo-bar', { + ComponentClass: FooBarComponent, + template: '{{yield greeting greetee.firstName}}', + }); + + this.render( + '{{name}} {{person.lastName}}, {{greeting}}', + { + person: { + firstName: 'Joel', + lastName: 'Kang', + }, + } + ); + + this.assertComponentElement(this.firstChild, { + content: 'Joel Kang, hello', + }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + content: 'Joel Kang, hello', + }); + + this.runTask(() => + set(this.context, 'person', { + firstName: 'Dora', + lastName: 'the Explorer', + }) + ); + + this.assertComponentElement(this.firstChild, { + content: 'Dora the Explorer, hello', + }); + + this.runTask(() => set(instance, 'greeting', 'hola')); + + this.assertComponentElement(this.firstChild, { + content: 'Dora the Explorer, hola', + }); + + this.runTask(() => { + set(instance, 'greeting', 'hello'); + set(this.context, 'person', { + firstName: 'Joel', + lastName: 'Kang', + }); + }); + + this.assertComponentElement(this.firstChild, { + content: 'Joel Kang, hello', + }); + } + + '@test positional parameters are not allowed'() { + this.registerComponent('sample-component', { + ComponentClass: Component.extend().reopenClass({ + positionalParams: ['name', 'age'], + }), + template: '{{name}}{{age}}', + }); + + // this is somewhat silly as the browser "corrects" for these as + // attribute names, but regardless the thing we care about here is that + // they are **not** used as positional params + this.render(''); + + this.assertText(''); + } + + '@skip can invoke curried components with capitalized block param names'() { + this.registerComponent('foo-bar', { template: 'hello' }); + + this.render(strip` + {{#with (component 'foo-bar') as |Other|}} + + {{/with}} + `); + + this.assertComponentElement(this.firstChild, { content: 'hello' }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { content: 'hello' }); + + this.assertStableRerender(); + } + + '@skip has-block'() { + this.registerComponent('check-block', { + template: strip` + {{#if (has-block)}} + Yes + {{else}} + No + {{/if}}`, + }); + + this.render(strip` + + `); + + this.assertComponentElement(this.firstChild, { content: 'No' }); + this.assertComponentElement(this.nthChild(1), { content: 'Yes' }); + + this.assertStableRerender(); + } + + '@skip includes invocation specified attributes in root element ("splattributes")'() { + this.registerComponent('foo-bar', { + ComponentClass: Component.extend(), + template: 'hello', + }); + + this.render('', { foo: 'foo', bar: 'bar' }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + + this.runTask(() => this.rerender()); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + + this.runTask(() => { + set(this.context, 'foo', 'FOO'); + set(this.context, 'bar', undefined); + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'FOO' }, + content: 'hello', + }); + + this.runTask(() => { + set(this.context, 'foo', 'foo'); + set(this.context, 'bar', 'bar'); + }); + + this.assertComponentElement(this.firstChild, { + tagName: 'div', + attrs: { 'data-foo': 'foo', 'data-bar': 'bar' }, + content: 'hello', + }); + } + } + ); +} diff --git a/packages/ember-glimmer/tests/integration/components/contextual-components-test.js b/packages/ember-glimmer/tests/integration/components/contextual-components-test.js index 37f9a58a4e8..cabc142f68b 100644 --- a/packages/ember-glimmer/tests/integration/components/contextual-components-test.js +++ b/packages/ember-glimmer/tests/integration/components/contextual-components-test.js @@ -1,3 +1,4 @@ +import { EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION } from '@ember/canary-features'; import { assign } from '@ember/polyfills'; import { Component } from '../../utils/helpers'; import { applyMixins, strip } from '../../utils/abstract-test-case'; @@ -1377,15 +1378,23 @@ moduleFor( } ['@test GH#14632 give useful warning when calling contextual components with input as a name']() { + let expectedMessage = EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION + ? "You cannot use 'input' as a component name. Component names must contain a hyphen or start with a capital letter." + : "You cannot use 'input' as a component name. Component names must contain a hyphen."; + expectAssertion(() => { this.render('{{component (component "input" type="text")}}'); - }, "You cannot use 'input' as a component name. Component names must contain a hyphen."); + }, expectedMessage); } ['@test GH#14632 give useful warning when calling contextual components with textarea as a name']() { + let expectedMessage = EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION + ? "You cannot use 'textarea' as a component name. Component names must contain a hyphen or start with a capital letter." + : "You cannot use 'textarea' as a component name. Component names must contain a hyphen."; + expectAssertion(() => { this.render('{{component (component "textarea" type="text")}}'); - }, "You cannot use 'textarea' as a component name. Component names must contain a hyphen."); + }, expectedMessage); } } ); diff --git a/packages/ember-glimmer/tests/integration/components/dynamic-components-test.js b/packages/ember-glimmer/tests/integration/components/dynamic-components-test.js index ed188318783..d46211f26e9 100644 --- a/packages/ember-glimmer/tests/integration/components/dynamic-components-test.js +++ b/packages/ember-glimmer/tests/integration/components/dynamic-components-test.js @@ -1,3 +1,4 @@ +import { EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION } from '@ember/canary-features'; import { set, computed } from 'ember-metal'; import { jQueryDisabled } from 'ember-views'; import { Component } from '../../utils/helpers'; @@ -579,7 +580,12 @@ moduleFor( this.assertText('[Robert - Robert][Jacquie - Jacquie]'); } - ['@test dashless components should not be found']() { + ['@test dashless components should not be found'](assert) { + if (EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION) { + assert.ok(true, 'test is not applicable'); + return; + } + this.registerComponent('dashless2', { template: 'Do not render me!' }); expectAssertion(() => { diff --git a/packages/ember-template-compiler/lib/plugins/assert-splattribute-expression.js b/packages/ember-template-compiler/lib/plugins/assert-splattribute-expression.js new file mode 100644 index 00000000000..fa44b7474aa --- /dev/null +++ b/packages/ember-template-compiler/lib/plugins/assert-splattribute-expression.js @@ -0,0 +1,33 @@ +import { assert } from '@ember/debug'; +import calculateLocationDisplay from '../system/calculate-location-display'; +import { EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION } from '@ember/canary-features'; + +export default function assertSplattributeExpressions(env) { + let { moduleName } = env.meta; + + return { + name: 'assert-splattribute-expressions', + + visitor: { + AttrNode({ name, loc }) { + if (!EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION && name === '...attributes') { + assert(`${errorMessage()} ${calculateLocationDisplay(moduleName, loc)}`); + } + }, + + PathExpression({ original, loc }) { + if (original === '...attributes') { + assert(`${errorMessage()} ${calculateLocationDisplay(moduleName, loc)}`); + } + }, + }, + }; +} + +function errorMessage() { + if (EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION) { + return `Using "...attributes" can only be used in the element position e.g.
. It cannot be used as a path.`; + } + + return `...attributes is an invalid path`; +} diff --git a/packages/ember-template-compiler/lib/plugins/index.js b/packages/ember-template-compiler/lib/plugins/index.js index 049f22cbdd0..2b9880cece9 100644 --- a/packages/ember-template-compiler/lib/plugins/index.js +++ b/packages/ember-template-compiler/lib/plugins/index.js @@ -16,6 +16,7 @@ import TransformDotComponentInvocation from './transform-dot-component-invocatio import AssertInputHelperWithoutBlock from './assert-input-helper-without-block'; import TransformInElement from './transform-in-element'; import AssertIfHelperWithoutArguments from './assert-if-helper-without-arguments'; +import AssertSplattributeExpressions from './assert-splattribute-expression'; const transforms = [ TransformDotComponentInvocation, @@ -36,6 +37,7 @@ const transforms = [ AssertInputHelperWithoutBlock, TransformInElement, AssertIfHelperWithoutArguments, + AssertSplattributeExpressions, ]; export default Object.freeze(transforms); diff --git a/packages/ember-template-compiler/tests/plugins/assert-splattribute-expression-test.js b/packages/ember-template-compiler/tests/plugins/assert-splattribute-expression-test.js new file mode 100644 index 00000000000..64ff178ad6c --- /dev/null +++ b/packages/ember-template-compiler/tests/plugins/assert-splattribute-expression-test.js @@ -0,0 +1,57 @@ +import { EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION } from '@ember/canary-features'; +import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; +import { compile } from '../../index'; + +moduleFor( + 'ember-template-compiler: assert-splattribute-expression', + class extends AbstractTestCase { + expectedMessage(locInfo) { + return EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION + ? `Using "...attributes" can only be used in the element position e.g.
. It cannot be used as a path. (${locInfo}) ` + : `...attributes is an invalid path (${locInfo}) `; + } + + '@test ...attributes is in element space'(assert) { + if (EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION) { + assert.expect(0); + + compile('
Foo
'); + } else { + expectAssertion(() => { + compile('
Foo
'); + }, this.expectedMessage('L1:C5')); + } + } + + '@test {{...attributes}} is not valid'() { + expectAssertion(() => { + compile('
{{...attributes}}
', { + moduleName: 'foo-bar', + }); + }, this.expectedMessage(`'foo-bar' @ L1:C7`)); + } + + '@test {{...attributes}} is not valid path expression'() { + expectAssertion(() => { + compile('
{{...attributes}}
', { + moduleName: 'foo-bar', + }); + }, this.expectedMessage(`'foo-bar' @ L1:C7`)); + } + '@test {{...attributes}} is not valid modifier'() { + expectAssertion(() => { + compile('
Wat
', { + moduleName: 'foo-bar', + }); + }, this.expectedMessage(`'foo-bar' @ L1:C7`)); + } + + '@test {{...attributes}} is not valid attribute'() { + expectAssertion(() => { + compile('
Wat
', { + moduleName: 'foo-bar', + }); + }, this.expectedMessage(`'foo-bar' @ L1:C13`)); + } + } +); diff --git a/packages/ember-views/lib/component_lookup.js b/packages/ember-views/lib/component_lookup.js index ed6277c1c2f..d54677d1445 100644 --- a/packages/ember-views/lib/component_lookup.js +++ b/packages/ember-views/lib/component_lookup.js @@ -1,22 +1,38 @@ +import { EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION } from '@ember/canary-features'; import { assert } from '@ember/debug'; +import { dasherize } from '@ember/string'; import { Object as EmberObject } from 'ember-runtime'; export default EmberObject.extend({ - componentFor(name, owner, options) { + componentFor(_name, owner, options) { assert( - `You cannot use '${name}' as a component name. Component names must contain a hyphen.`, - ~name.indexOf('-') + `You cannot use '${_name}' as a component name. Component names must contain a hyphen${ + EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION ? ' or start with a capital letter' : '' + }.`, + _name.indexOf('-') > -1 || + (EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION && + _name.charAt(0) === _name.charAt(0).toUpperCase()) ); + let name = + EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION && _name.charAt(0) === _name.charAt(0).toUpperCase() + ? dasherize(_name) + : _name; let fullName = `component:${name}`; return owner.factoryFor(fullName, options); }, - layoutFor(name, owner, options) { + layoutFor(_name, owner, options) { assert( - `You cannot use '${name}' as a component name. Component names must contain a hyphen.`, - ~name.indexOf('-') + `You cannot use '${_name}' as a component name. Component names must contain a hyphen.`, + _name.indexOf('-') > -1 || + (EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION && + _name.charAt(0) === _name.charAt(0).toUpperCase()) ); + let name = + EMBER_GLIMMER_ANGLE_BRACKET_INVOCATION && _name.charAt(0) === _name.charAt(0).toUpperCase() + ? dasherize(_name) + : _name; let templateFullName = `template:components/${name}`; return owner.lookup(templateFullName, options); }, diff --git a/packages/internal-test-helpers/lib/module-for.js b/packages/internal-test-helpers/lib/module-for.js index 941ba1e5dbf..e4468190b4c 100644 --- a/packages/internal-test-helpers/lib/module-for.js +++ b/packages/internal-test-helpers/lib/module-for.js @@ -73,7 +73,7 @@ export default function moduleFor(description, TestClass, ...mixins) { return this.instance[name](assert); }); } else { - let match = /^@feature\(([a-z-!]+)\) /.exec(name); + let match = /^@feature\(([A-Z_a-z-!]+)\) /.exec(name); if (match) { let features = match[1].replace(/ /g, '').split(',');