From e7866ca9f37d2ac417f4fdb98331a99a6199ba1c Mon Sep 17 00:00:00 2001 From: Tom Dale Date: Fri, 5 Jun 2015 18:18:20 -0700 Subject: [PATCH] Implements RFC #60 (Component Unification) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/emberjs/rfcs/pull/60 This commit implements the proposed semantics for angle-bracket components. The TLDR is that a component’s template represents its “outerHTML” rather than its “innerHTML”, which makes it easier to configure the element itself using templating constructs. Some salient points: 1. If there is a single root element, the attributes from the invocation are copied onto the root element. 2. The invocation’s attributes “win out” over the attributes from the root element in the component’s layout. 3. Classes are merged. If the invocation says `class=“a”` and the root element says `class=“b”`, the result is `class=“a b”`. The rationale is that when you say `class=“a”`, you are really saying “add the `a` class to this element”. A few sticky issues: 1. If the root element for the `my-foo` component is ``, we insert an element with tag name of `my-foo`. While this is intuitive, note that in all other cases, `` represents an invocation of the component. In the root position, that makes no sense, since it would inevitably produce a stack overflow. a. This “identity element” case makes it idiomatic to reflect the name of the component onto the DOM. b. Unfortunately, when we compile a template, we do not yet know what component that template is used for, and, indeed, whether it is even a template for a component at all. c. To capture the semantic differences, we do a bit of analysis at compile time (to determine *candidates* for top-level elements), and use runtime information (component invocation style and the name of the component looked up in the container) to disambiguate between a component’s element and an invocation of another component. 2. If the root element for the `my-foo` component is a regular HTML element, we use the `attachAttributes` functionality of HTMLBars to attach the attributes that the component was invoked with onto the root element. 3. In general, it is important that changes to attributes do not result in a change to the identity of the element that they are contained in. To achieve this, we reused much of the infrastructure built in `buildComponentTemplate`. The consequence of (1) and (2) above is that the semantics are always “a component’s layout represents its outerHTML”, even though the implementation is quite different depending on whether the root element is the same-named component or not. --- packages/ember-htmlbars/lib/env.js | 50 +-- .../ember-htmlbars/lib/hooks/attributes.js | 50 +++ .../ember-htmlbars/lib/hooks/component.js | 50 ++- .../node-managers/component-node-manager.js | 2 +- .../integration/component_invocation_test.js | 395 +++++++++++------- packages/ember-template-compiler/lib/main.js | 2 + .../plugins/transform-top-level-components.js | 46 ++ .../lib/system/compile_options.js | 35 ++ .../lib/system/build-component-template.js | 40 +- tests/node/template-compiler-test.js | 4 +- 10 files changed, 469 insertions(+), 205 deletions(-) create mode 100644 packages/ember-htmlbars/lib/hooks/attributes.js create mode 100644 packages/ember-template-compiler/lib/plugins/transform-top-level-components.js diff --git a/packages/ember-htmlbars/lib/env.js b/packages/ember-htmlbars/lib/env.js index e0fd6fb1646..74c5bbd07d2 100644 --- a/packages/ember-htmlbars/lib/env.js +++ b/packages/ember-htmlbars/lib/env.js @@ -28,6 +28,7 @@ import lookupHelper from 'ember-htmlbars/hooks/lookup-helper'; import hasHelper from 'ember-htmlbars/hooks/has-helper'; import invokeHelper from 'ember-htmlbars/hooks/invoke-helper'; import element from 'ember-htmlbars/hooks/element'; +import attributes from 'ember-htmlbars/hooks/attributes'; import helpers from 'ember-htmlbars/helpers'; import keywords, { registerKeyword } from 'ember-htmlbars/keywords'; @@ -38,30 +39,31 @@ var emberHooks = merge({}, hooks); emberHooks.keywords = keywords; merge(emberHooks, { - linkRenderNode: linkRenderNode, - createFreshScope: createFreshScope, - bindShadowScope: bindShadowScope, - bindSelf: bindSelf, - bindScope: bindScope, - bindLocal: bindLocal, - updateSelf: updateSelf, - getRoot: getRoot, - getChild: getChild, - getValue: getValue, - getCellOrValue: getCellOrValue, - subexpr: subexpr, - concat: concat, - cleanupRenderNode: cleanupRenderNode, - destroyRenderNode: destroyRenderNode, - willCleanupTree: willCleanupTree, - didCleanupTree: didCleanupTree, - didRenderNode: didRenderNode, - classify: classify, - component: component, - lookupHelper: lookupHelper, - hasHelper: hasHelper, - invokeHelper: invokeHelper, - element: element + linkRenderNode, + createFreshScope, + bindShadowScope, + bindSelf, + bindScope, + bindLocal, + updateSelf, + getRoot, + getChild, + getValue, + getCellOrValue, + subexpr, + concat, + cleanupRenderNode, + destroyRenderNode, + willCleanupTree, + didCleanupTree, + didRenderNode, + classify, + component, + lookupHelper, + hasHelper, + invokeHelper, + element, + attributes }); import debuggerKeyword from 'ember-htmlbars/keywords/debugger'; diff --git a/packages/ember-htmlbars/lib/hooks/attributes.js b/packages/ember-htmlbars/lib/hooks/attributes.js new file mode 100644 index 00000000000..c622eaa3a82 --- /dev/null +++ b/packages/ember-htmlbars/lib/hooks/attributes.js @@ -0,0 +1,50 @@ +import { render, internal } from 'htmlbars-runtime'; + +export default function attributes(morph, env, scope, template, parentNode, visitor) { + let state = morph.state; + let block = state.block; + + if (!block) { + let element = findRootElement(parentNode); + if (!element) { return; } + + normalizeClassStatement(template.statements, element); + + template.element = element; + block = morph.state.block = internal.blockFor(render, template, { scope }); + } + + block(env, [], undefined, morph, undefined, visitor); +} + +function normalizeClassStatement(statements, element) { + let className = element.getAttribute('class'); + if (!className) { return; } + + for (let i=0, l=statements.length; i$/); + + if (angles) { + tagName = angles[2]; isAngleBracket = true; + isTopLevel = !!angles[1]; } var parentView = env.view; - var manager = ComponentNodeManager.create(renderNode, env, { - tagName, - params, - attrs, - parentView, - templates, - isAngleBracket, - parentScope: scope - }); - - state.manager = manager; + if (!isTopLevel || tagName !== env.view.tagName) { + var manager = ComponentNodeManager.create(renderNode, env, { + tagName, + params, + attrs, + parentView, + templates, + isAngleBracket, + isTopLevel, + parentScope: scope + }); + + state.manager = manager; + manager.render(env, visitor); + } else { + let component = env.view; + let templateOptions = { + component, + isAngleBracket: true, + isComponentElement: true, + outerAttrs: scope.attrs, + parentScope: scope + }; + + let contentOptions = { templates, scope }; + + let { block } = buildComponentTemplate(templateOptions, attrs, contentOptions); + block(env, [], undefined, renderNode, scope, visitor); + } - manager.render(env, visitor); } diff --git a/packages/ember-htmlbars/lib/node-managers/component-node-manager.js b/packages/ember-htmlbars/lib/node-managers/component-node-manager.js index 5c680a85d61..cce197107fa 100644 --- a/packages/ember-htmlbars/lib/node-managers/component-node-manager.js +++ b/packages/ember-htmlbars/lib/node-managers/component-node-manager.js @@ -87,7 +87,7 @@ ComponentNodeManager.create = function(renderNode, env, options) { extractPositionalParams(renderNode, component, params, attrs); - var results = buildComponentTemplate( + let results = buildComponentTemplate( { layout, component, isAngleBracket }, attrs, { templates, scope: parentScope } ); diff --git a/packages/ember-htmlbars/tests/integration/component_invocation_test.js b/packages/ember-htmlbars/tests/integration/component_invocation_test.js index 826abdfb119..9c3ab497c88 100644 --- a/packages/ember-htmlbars/tests/integration/component_invocation_test.js +++ b/packages/ember-htmlbars/tests/integration/component_invocation_test.js @@ -906,229 +906,318 @@ QUnit.test('non-block with each rendering child components', function() { if (isEnabled('ember-htmlbars-component-generation')) { QUnit.module('component - invocation (angle brackets)', { - setup() { - commonSetup(); - }, + setup() { + commonSetup(); + }, - teardown() { - commonTeardown(); - } -}); + teardown() { + commonTeardown(); + } + }); - QUnit.test('non-block without properties', function() { - registry.register('template:components/non-block', compile('In layout')); + QUnit.test('non-block without properties replaced with a fragment when the content is just text', function() { + registry.register('template:components/non-block', compile('In layout')); - view = appendViewFor(''); + view = appendViewFor(''); - equal(view.$().text(), 'In layout'); - ok(view.$('non-block.ember-view').length === 1, 'The non-block tag name was used'); -}); + equal(view.$().html(), 'In layout', 'Just the fragment was used'); + }); - QUnit.test('block without properties', function() { - registry.register('template:components/with-block', compile('In layout - {{yield}}')); + QUnit.test('non-block without properties replaced with a fragment when the content is multiple elements', function() { + registry.register('template:components/non-block', compile('
This is a
fragment
')); - view = appendViewFor('In template'); + view = appendViewFor(''); - equal(view.$('with-block.ember-view').text(), 'In layout - In template', 'Both the layout and template are rendered'); -}); + equal(view.$().html(), '
This is a
fragment
', 'Just the fragment was used'); + }); - QUnit.test('non-block with properties on attrs', function() { - registry.register('template:components/non-block', compile('In layout')); + QUnit.test('non-block without properties replaced with a div', function() { + // The whitespace is added intentionally to verify that the heuristic is not "a single node" but + // rather "a single non-whitespace, non-comment node" + registry.register('template:components/non-block', compile('
In layout
')); - view = appendViewFor('', { - dynamic: 'dynamic' + view = appendViewFor(''); + + equal(view.$().text(), ' In layout '); + ok(view.$().html().match(/^
In layout<\/div> $/), 'The root element has gotten the default class and ids'); + ok(view.$('div.ember-view[id]').length === 1, 'The div became an Ember view'); + + run(view, 'rerender'); + + equal(view.$().text(), ' In layout '); + ok(view.$().html().match(/^
In layout<\/div> $/), 'The root element has gotten the default class and ids'); + ok(view.$('div.ember-view[id]').length === 1, 'The non-block tag name was used'); }); - let el = view.$('non-block.ember-view'); - ok(el, 'precond - the view was rendered'); - equal(el.attr('static-prop'), 'static text'); - equal(el.attr('concat-prop'), 'dynamic text'); - equal(el.attr('dynamic-prop'), undefined); + QUnit.test('non-block without properties replaced with identity element', function() { + registry.register('template:components/non-block', compile('In layout')); - //equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: something here'); -}); + view = appendViewFor('', { + stability: 'stability' + }); - QUnit.test('attributes are not installed on the top level', function() { - let component; + let node = view.$()[0]; + equal(view.$().text(), 'In layout'); + ok(view.$().html().match(/^In layout<\/non-block>$/), 'The root element has gotten the default class and ids'); + ok(view.$('non-block.ember-view[id][such=stability]').length === 1, 'The non-block tag name was used'); - registry.register('template:components/non-block', compile('In layout - {{attrs.text}}')); - registry.register('component:non-block', Component.extend({ - text: null, - dynamic: null, + run(() => view.set('stability', 'stability!')); - didInitAttrs() { - component = this; - } - })); + strictEqual(view.$()[0], node, 'the DOM node has remained stable'); + equal(view.$().text(), 'In layout'); + ok(view.$().html().match(/^In layout<\/non-block>$/), 'The root element has gotten the default class and ids'); + }); + + QUnit.test('non-block with class replaced with a div merges classes', function() { + registry.register('template:components/non-block', compile('
')); - view = appendViewFor('', { - dynamic: 'dynamic' + view = appendViewFor('', { + outer: 'outer' + }); + + equal(view.$('div').attr('class'), 'inner-class outer ember-view', 'the classes are merged'); + + run(() => view.set('outer', 'new-outer')); + + equal(view.$('div').attr('class'), 'inner-class new-outer ember-view', 'the classes are merged'); }); - let el = view.$('non-block.ember-view'); - ok(el, 'precond - the view was rendered'); + QUnit.test('non-block with class replaced with a identity element merges classes', function() { + registry.register('template:components/non-block', compile('')); - equal(el.text(), 'In layout - texting'); - equal(component.attrs.text, 'texting'); - equal(component.attrs.dynamic, 'dynamic'); - strictEqual(get(component, 'text'), null); - strictEqual(get(component, 'dynamic'), null); + view = appendViewFor('', { + outer: 'outer' + }); - run(() => view.rerender()); + equal(view.$('non-block').attr('class'), 'inner-class outer ember-view', 'the classes are merged'); - equal(el.text(), 'In layout - texting'); - equal(component.attrs.text, 'texting'); - equal(component.attrs.dynamic, 'dynamic'); - strictEqual(get(component, 'text'), null); - strictEqual(get(component, 'dynamic'), null); -}); + run(() => view.set('outer', 'new-outer')); - QUnit.test('non-block with properties on attrs and component class', function() { - registry.register('component:non-block', Component.extend()); - registry.register('template:components/non-block', compile('In layout - someProp: {{attrs.someProp}}')); + equal(view.$('non-block').attr('class'), 'inner-class new-outer ember-view', 'the classes are merged'); + }); - view = appendViewFor(''); + QUnit.test('non-block rendering a fragment', function() { + registry.register('template:components/non-block', compile('

{{attrs.first}}

{{attrs.second}}

')); - equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: something here'); -}); + view = appendViewFor('', { + first: 'first1', + second: 'second1' + }); - QUnit.test('rerendering component with attrs from parent', function() { - var willUpdate = 0; - var didReceiveAttrs = 0; + equal(view.$().html(), '

first1

second1

', 'No wrapping element was created'); - registry.register('component:non-block', Component.extend({ - didReceiveAttrs() { - didReceiveAttrs++; - }, + run(view, 'setProperties', { + first: 'first2', + second: 'second2' + }); - willUpdate() { - willUpdate++; - } - })); + equal(view.$().html(), '

first2

second2

', 'The fragment was updated'); + }); - registry.register('template:components/non-block', compile('In layout - someProp: {{attrs.someProp}}')); + QUnit.test('block without properties', function() { + registry.register('template:components/with-block', compile('In layout - {{yield}}')); - view = appendViewFor('', { - someProp: 'wycats' + view = appendViewFor('In template'); + + equal(view.$('with-block.ember-view').text(), 'In layout - In template', 'Both the layout and template are rendered'); }); - equal(didReceiveAttrs, 1, 'The didReceiveAttrs hook fired'); + QUnit.test('non-block with properties on attrs', function() { + registry.register('template:components/non-block', compile('In layout')); - equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: wycats'); + view = appendViewFor('', { + dynamic: 'dynamic' + }); - run(function() { - view.set('someProp', 'tomdale'); + let el = view.$('non-block.ember-view'); + ok(el, 'precond - the view was rendered'); + equal(el.attr('static-prop'), 'static text'); + equal(el.attr('concat-prop'), 'dynamic text'); + equal(el.attr('dynamic-prop'), undefined); + + //equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: something here'); }); - equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: tomdale'); - equal(didReceiveAttrs, 2, 'The didReceiveAttrs hook fired again'); - equal(willUpdate, 1, 'The willUpdate hook fired once'); + QUnit.test('attributes are not installed on the top level', function() { + let component; - Ember.run(view, 'rerender'); + registry.register('template:components/non-block', compile('In layout - {{attrs.text}}')); + registry.register('component:non-block', Component.extend({ + text: null, + dynamic: null, - equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: tomdale'); - equal(didReceiveAttrs, 3, 'The didReceiveAttrs hook fired again'); - equal(willUpdate, 2, 'The willUpdate hook fired again'); -}); + didInitAttrs() { + component = this; + } + })); + + view = appendViewFor('', { + dynamic: 'dynamic' + }); + + let el = view.$('non-block.ember-view'); + ok(el, 'precond - the view was rendered'); + + equal(el.text(), 'In layout - texting'); + equal(component.attrs.text, 'texting'); + equal(component.attrs.dynamic, 'dynamic'); + strictEqual(get(component, 'text'), null); + strictEqual(get(component, 'dynamic'), null); + + run(() => view.rerender()); + + equal(el.text(), 'In layout - texting'); + equal(component.attrs.text, 'texting'); + equal(component.attrs.dynamic, 'dynamic'); + strictEqual(get(component, 'text'), null); + strictEqual(get(component, 'dynamic'), null); + }); + + QUnit.test('non-block with properties on attrs and component class', function() { + registry.register('component:non-block', Component.extend()); + registry.register('template:components/non-block', compile('In layout - someProp: {{attrs.someProp}}')); + + view = appendViewFor(''); + + equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: something here'); + }); + + QUnit.test('rerendering component with attrs from parent', function() { + var willUpdate = 0; + var didReceiveAttrs = 0; + + registry.register('component:non-block', Component.extend({ + didReceiveAttrs() { + didReceiveAttrs++; + }, + + willUpdate() { + willUpdate++; + } + })); + + registry.register('template:components/non-block', compile('In layout - someProp: {{attrs.someProp}}')); + + view = appendViewFor('', { + someProp: 'wycats' + }); + + equal(didReceiveAttrs, 1, 'The didReceiveAttrs hook fired'); + + equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: wycats'); + + run(function() { + view.set('someProp', 'tomdale'); + }); + + equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: tomdale'); + equal(didReceiveAttrs, 2, 'The didReceiveAttrs hook fired again'); + equal(willUpdate, 1, 'The willUpdate hook fired once'); + + Ember.run(view, 'rerender'); + + equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: tomdale'); + equal(didReceiveAttrs, 3, 'The didReceiveAttrs hook fired again'); + equal(willUpdate, 2, 'The willUpdate hook fired again'); + }); QUnit.test('block with properties on attrs', function() { - registry.register('template:components/with-block', compile('In layout - someProp: {{attrs.someProp}} - {{yield}}')); + registry.register('template:components/with-block', compile('In layout - someProp: {{attrs.someProp}} - {{yield}}')); - view = appendViewFor('In template'); + view = appendViewFor('In template'); - equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: something here - In template'); -}); + equal(jQuery('#qunit-fixture').text(), 'In layout - someProp: something here - In template'); + }); QUnit.test('moduleName is available on _renderNode when a layout is present', function() { - expect(1); + expect(1); - var layoutModuleName = 'my-app-name/templates/components/sample-component'; - var sampleComponentLayout = compile('Sample Component - {{yield}}', { - moduleName: layoutModuleName - }); - registry.register('template:components/sample-component', sampleComponentLayout); - registry.register('component:sample-component', Component.extend({ - didInsertElement: function() { - equal(this._renderNode.lastResult.template.meta.moduleName, layoutModuleName); - } - })); + var layoutModuleName = 'my-app-name/templates/components/sample-component'; + var sampleComponentLayout = compile('Sample Component - {{yield}}', { + moduleName: layoutModuleName + }); + registry.register('template:components/sample-component', sampleComponentLayout); + registry.register('component:sample-component', Component.extend({ + didInsertElement: function() { + equal(this._renderNode.lastResult.template.meta.moduleName, layoutModuleName); + } + })); - view = EmberView.extend({ - layout: compile(''), - container - }).create(); + view = EmberView.extend({ + layout: compile(''), + container + }).create(); - runAppend(view); -}); + runAppend(view); + }); QUnit.test('moduleName is available on _renderNode when no layout is present', function() { - expect(1); + expect(1); - var templateModuleName = 'my-app-name/templates/application'; - registry.register('component:sample-component', Component.extend({ - didInsertElement: function() { - equal(this._renderNode.lastResult.template.meta.moduleName, templateModuleName); - } - })); + var templateModuleName = 'my-app-name/templates/application'; + registry.register('component:sample-component', Component.extend({ + didInsertElement: function() { + equal(this._renderNode.lastResult.template.meta.moduleName, templateModuleName); + } + })); - view = EmberView.extend({ - layout: compile('{{#sample-component}}Derp{{/sample-component}}', { - moduleName: templateModuleName - }), - container - }).create(); + view = EmberView.extend({ + layout: compile('{{#sample-component}}Derp{{/sample-component}}', { + moduleName: templateModuleName + }), + container + }).create(); - runAppend(view); -}); + runAppend(view); + }); QUnit.test('parameterized hasBlock default', function() { - registry.register('template:components/check-block', compile('{{#if (hasBlock)}}Yes{{else}}No{{/if}}')); + registry.register('template:components/check-block', compile('{{#if (hasBlock)}}Yes{{else}}No{{/if}}')); - view = appendViewFor(' '); + view = appendViewFor(' '); - equal(view.$('#expect-yes-1').text(), 'Yes'); - equal(view.$('#expect-yes-2').text(), 'Yes'); -}); + equal(view.$('#expect-yes-1').text(), 'Yes'); + equal(view.$('#expect-yes-2').text(), 'Yes'); + }); QUnit.test('non-expression hasBlock ', function() { - registry.register('template:components/check-block', compile('{{#if hasBlock}}Yes{{else}}No{{/if}}')); + registry.register('template:components/check-block', compile('{{#if hasBlock}}Yes{{else}}No{{/if}}')); - view = appendViewFor(' '); + view = appendViewFor(' '); - equal(view.$('#expect-yes-1').text(), 'Yes'); - equal(view.$('#expect-yes-2').text(), 'Yes'); -}); + equal(view.$('#expect-yes-1').text(), 'Yes'); + equal(view.$('#expect-yes-2').text(), 'Yes'); + }); QUnit.test('parameterized hasBlockParams', function() { - registry.register('template:components/check-params', compile('{{#if (hasBlockParams)}}Yes{{else}}No{{/if}}')); + registry.register('template:components/check-params', compile('{{#if (hasBlockParams)}}Yes{{else}}No{{/if}}')); - view = appendViewFor(' '); + view = appendViewFor(' '); - equal(view.$('#expect-no').text(), 'No'); - equal(view.$('#expect-yes').text(), 'Yes'); -}); + equal(view.$('#expect-no').text(), 'No'); + equal(view.$('#expect-yes').text(), 'Yes'); + }); QUnit.test('non-expression hasBlockParams', function() { - registry.register('template:components/check-params', compile('{{#if hasBlockParams}}Yes{{else}}No{{/if}}')); + registry.register('template:components/check-params', compile('{{#if hasBlockParams}}Yes{{else}}No{{/if}}')); - view = appendViewFor(' '); + view = appendViewFor(' '); - equal(view.$('#expect-no').text(), 'No'); - equal(view.$('#expect-yes').text(), 'Yes'); -}); + equal(view.$('#expect-no').text(), 'No'); + equal(view.$('#expect-yes').text(), 'Yes'); + }); QUnit.test('implementing `render` allows pushing into a string buffer', function() { - expect(1); + expect(1); - registry.register('component:non-block', Component.extend({ - render(buffer) { - buffer.push('Whoop!'); - } - })); + registry.register('component:non-block', Component.extend({ + render(buffer) { + buffer.push('Whoop!'); + } + })); - expectAssertion(function() { - appendViewFor(''); + expectAssertion(function() { + appendViewFor(''); + }); }); -}); - } diff --git a/packages/ember-template-compiler/lib/main.js b/packages/ember-template-compiler/lib/main.js index 113f8d617a7..b8fafc725cf 100644 --- a/packages/ember-template-compiler/lib/main.js +++ b/packages/ember-template-compiler/lib/main.js @@ -17,6 +17,7 @@ import TransformComponentCurlyToReadonly from 'ember-template-compiler/plugins/t import TransformAngleBracketComponents from 'ember-template-compiler/plugins/transform-angle-bracket-components'; import TransformInputOnToOnEvent from 'ember-template-compiler/plugins/transform-input-on-to-onEvent'; import DeprecateViewAndControllerPaths from 'ember-template-compiler/plugins/deprecate-view-and-controller-paths'; +import TransformTopLevelComponents from 'ember-template-compiler/plugins/transform-top-level-components'; import DeprecateViewHelper from 'ember-template-compiler/plugins/deprecate-view-helper'; // used for adding Ember.Handlebars.compile for backwards compat @@ -34,6 +35,7 @@ registerPlugin('ast', TransformComponentAttrsIntoMut); registerPlugin('ast', TransformComponentCurlyToReadonly); registerPlugin('ast', TransformAngleBracketComponents); registerPlugin('ast', TransformInputOnToOnEvent); +registerPlugin('ast', TransformTopLevelComponents); registerPlugin('ast', DeprecateViewAndControllerPaths); registerPlugin('ast', DeprecateViewHelper); diff --git a/packages/ember-template-compiler/lib/plugins/transform-top-level-components.js b/packages/ember-template-compiler/lib/plugins/transform-top-level-components.js new file mode 100644 index 00000000000..399cafdd601 --- /dev/null +++ b/packages/ember-template-compiler/lib/plugins/transform-top-level-components.js @@ -0,0 +1,46 @@ +function TransformTopLevelComponents() { + // set later within HTMLBars to the syntax package + this.syntax = null; +} + +/** + @private + @method transform + @param {AST} The AST to be transformed. +*/ +TransformTopLevelComponents.prototype.transform = function TransformTopLevelComponents_transform(ast) { + hasSingleComponentNode(ast.body, component => { + component.tag = `@${component.tag}`; + }); + + return ast; +}; + +function hasSingleComponentNode(body, callback) { + let lastComponentNode; + let lastIndex; + let nodeCount = 0; + + for (let i=0, l=body.length; i 0) { return false; } + + if (curr.type === 'ComponentNode' || curr.type === 'ElementNode') { + lastComponentNode = curr; + lastIndex = i; + } + } + + if (!lastComponentNode) { return; } + + if (lastComponentNode.type === 'ComponentNode') { + callback(lastComponentNode); + } +} + +export default TransformTopLevelComponents; diff --git a/packages/ember-template-compiler/lib/system/compile_options.js b/packages/ember-template-compiler/lib/system/compile_options.js index 40017e42539..599570814f3 100644 --- a/packages/ember-template-compiler/lib/system/compile_options.js +++ b/packages/ember-template-compiler/lib/system/compile_options.js @@ -40,6 +40,7 @@ export default function(_options) { options.buildMeta = function buildMeta(program) { return { + topLevel: detectTopLevel(program), revision: 'Ember@VERSION_STRING_PLACEHOLDER', loc: program.loc, moduleName: options.moduleName @@ -48,3 +49,37 @@ export default function(_options) { return options; } + +function detectTopLevel(program) { + let { loc, body } = program; + if (!loc || loc.start.line !== 1 || loc.start.column !== 0) { return null; } + + let lastComponentNode; + let lastIndex; + let nodeCount = 0; + + for (let i=0, l=body.length; i 0) { return false; } + + if (curr.type === 'ComponentNode' || curr.type === 'ElementNode') { + lastComponentNode = curr; + lastIndex = i; + } + } + + if (!lastComponentNode) { return null; } + + if (lastComponentNode.type === 'ComponentNode') { + let tag = lastComponentNode.tag; + if (tag.charAt(0) !== '<') { return null; } + return tag.slice(1, -1); + } + + return null; +} diff --git a/packages/ember-views/lib/system/build-component-template.js b/packages/ember-views/lib/system/build-component-template.js index dcb19fa1e1f..e7d8ad26400 100644 --- a/packages/ember-views/lib/system/build-component-template.js +++ b/packages/ember-views/lib/system/build-component-template.js @@ -1,10 +1,12 @@ import Ember from 'ember-metal/core'; import { get } from 'ember-metal/property_get'; +import { assign } from 'ember-metal/merge'; import { isGlobal } from 'ember-metal/path_cache'; import { internal, render } from 'htmlbars-runtime'; import getValue from 'ember-htmlbars/hooks/get-value'; +import { isStream } from 'ember-metal/streams/utils'; -export default function buildComponentTemplate({ component, layout, isAngleBracket}, attrs, content) { +export default function buildComponentTemplate({ component, layout, isAngleBracket, isComponentElement, outerAttrs }, attrs, content) { var blockToRender, tagName, meta; if (component === undefined) { @@ -12,21 +14,25 @@ export default function buildComponentTemplate({ component, layout, isAngleBrack } if (layout && layout.raw) { + let attributes = (component && component._isAngleBracket) ? normalizeComponentAttributes(component, true, attrs) : undefined; + let yieldTo = createContentBlocks(content.templates, content.scope, content.self, component); - blockToRender = createLayoutBlock(layout.raw, yieldTo, content.self, component, attrs); + blockToRender = createLayoutBlock(layout.raw, yieldTo, content.self, component, attrs, attributes); meta = layout.raw.meta; } else if (content.templates && content.templates.default) { - blockToRender = createContentBlock(content.templates.default, content.scope, content.self, component); + let attributes = (component && component._isAngleBracket) ? normalizeComponentAttributes(component, true, attrs) : undefined; + blockToRender = createContentBlock(content.templates.default, content.scope, content.self, component, attributes); meta = content.templates.default.meta; } - if (component) { + if (component && !component._isAngleBracket || isComponentElement) { tagName = tagNameFor(component); // If this is not a tagless component, we need to create the wrapping // element. We use `manualElement` to create a template that represents // the wrapping element and yields to the previous block. if (tagName !== '') { + if (isComponentElement) { attrs = mergeAttrs(attrs, outerAttrs); } var attributes = normalizeComponentAttributes(component, isAngleBracket, attrs); var elementTemplate = internal.manualElement(tagName, attributes); elementTemplate.meta = meta; @@ -44,17 +50,28 @@ export default function buildComponentTemplate({ component, layout, isAngleBrack return { createdElement: !!tagName, block: blockToRender }; } +function mergeAttrs(innerAttrs, outerAttrs) { + let result = assign({}, innerAttrs, outerAttrs); + + if (innerAttrs.class && outerAttrs.class) { + result.class = ['subexpr', '-join-classes', [['value', innerAttrs.class], ['value', outerAttrs.class]], []]; + } + + return result; +} + function blockFor(template, options) { Ember.assert('BUG: Must pass a template to blockFor', !!template); return internal.blockFor(render, template, options); } -function createContentBlock(template, scope, self, component) { +function createContentBlock(template, scope, self, component, attributes) { Ember.assert('BUG: buildComponentTemplate can take a scope or a self, but not both', !(scope && self)); return blockFor(template, { - scope: scope, - self: self, + scope, + self, + attributes, options: { view: component } }); } @@ -75,9 +92,10 @@ function createContentBlocks(templates, scope, self, component) { return output; } -function createLayoutBlock(template, yieldTo, self, component, attrs) { +function createLayoutBlock(template, yieldTo, self, component, attrs, attributes) { return blockFor(template, { yieldTo, + attributes, // If we have an old-style Controller with a template it will be // passed as our `self` argument, and it should be the context for @@ -197,10 +215,10 @@ function normalizeClass(component, attrs) { var classNameBindings = get(component, 'classNameBindings'); if (attrs.class) { - if (typeof attrs.class === 'string') { - normalizedClass.push(attrs.class); - } else { + if (isStream(attrs.class)) { normalizedClass.push(['subexpr', '-normalize-class', [['value', attrs.class.path], ['value', attrs.class]], []]); + } else { + normalizedClass.push(attrs.class); } } diff --git a/tests/node/template-compiler-test.js b/tests/node/template-compiler-test.js index 9206a6c0128..3284f135638 100644 --- a/tests/node/template-compiler-test.js +++ b/tests/node/template-compiler-test.js @@ -2,12 +2,12 @@ var path = require('path'); var distPath = path.join(__dirname, '../../dist'); -var emberPath = path.join(distPath, 'ember.debug.cjs'); var templateCompilerPath = path.join(distPath, 'ember-template-compiler'); var module = QUnit.module; var ok = QUnit.ok; var equal = QUnit.equal; +var test = QUnit.test; var distPath = path.join(__dirname, '../../dist'); var templateCompiler = require(path.join(distPath, 'ember-template-compiler')); @@ -53,7 +53,7 @@ test('allows enabling of features', function() { templateCompiler._Ember.FEATURES['ember-htmlbars-component-generation'] = true; templateOutput = templateCompiler.precompile(''); - ok(templateOutput.indexOf('["component","",[],0]') > -1, 'component generation can be enabled'); + ok(templateOutput.indexOf('["component","@",[],0]') > -1, 'component generation can be enabled'); } else { ok(true, 'cannot test features in feature stripped build'); }