diff --git a/packages/ember-glimmer/lib/component-managers/curly.ts b/packages/ember-glimmer/lib/component-managers/curly.ts index 220b802cc04..c937c2e7408 100644 --- a/packages/ember-glimmer/lib/component-managers/curly.ts +++ b/packages/ember-glimmer/lib/component-managers/curly.ts @@ -14,6 +14,7 @@ import { Arguments, Bounds, ComponentDefinition, + ComponentManager, ElementOperations, Invocation, PreparedArguments, @@ -39,13 +40,13 @@ import { getOwner, guidFor, } from 'ember-utils'; -import { OwnedTemplateMeta, setViewElement } from 'ember-views'; +import { addChildView, OwnedTemplateMeta, setViewElement } from 'ember-views'; import { BOUNDS, DIRTY_TAG, HAS_BLOCK, IS_DISPATCHING_ATTRS, - ROOT_REF, + ROOT_REF } from '../component'; import Environment from '../environment'; import { DynamicScope } from '../renderer'; @@ -198,23 +199,43 @@ export default class CurlyComponentManager extends AbstractManager, hasBlock: boolean): ComponentStateBucket { if (DEBUG) { this._pushToDebugStack(`component:${state.name}`, environment); } + // Get the nearest concrete component instance from the scope. "Virtual" + // components will be skipped. let parentView = dynamicScope.view; + // Get the Ember.Component subclass to instantiate for this component. let factory = state.ComponentClass; + // Capture the arguments, which tells Glimmer to give us our own, stable + // copy of the Arguments object that is safe to hold on to between renders. let capturedArgs = args.named.capture(); let props = processComponentArgs(capturedArgs); + // Alias `id` argument to `elementId` property on the component instance. aliasIdToElementId(args, props); + // Set component instance's parentView property to point to nearest concrete + // component. props.parentView = parentView; + + // Set whether this component was invoked with a block + // (`{{#my-component}}{{/my-component}}`) or without one + // (`{{my-component}}`). props[HAS_BLOCK] = hasBlock; + // Save the current `this` context of the template as the component's + // `_targetObject`, so bubbled actions are routed to the right place. props._targetObject = callerSelfRef.value(); // static layout asserts CurriedDefinition @@ -222,14 +243,20 @@ export default class CurlyComponentManager extends AbstractManager, template: OwnedTemplate, args?: CurriedArgs) { + constructor(public name: string, public manager: ComponentManager = CURLY_COMPONENT_MANAGER, public ComponentClass: any, public handle: Option, template: OwnedTemplate, args?: CurriedArgs) { const layout = template && template.asLayout(); const symbolTable = layout ? layout.symbolTable : undefined; this.symbolTable = symbolTable; diff --git a/packages/ember-glimmer/lib/component-managers/custom.ts b/packages/ember-glimmer/lib/component-managers/custom.ts new file mode 100644 index 00000000000..45fb087df08 --- /dev/null +++ b/packages/ember-glimmer/lib/component-managers/custom.ts @@ -0,0 +1,149 @@ +import { ComponentCapabilities, Opaque, Option } from '@glimmer/interfaces'; +import { PathReference, Tag } from '@glimmer/reference'; +import { Arguments, Bounds, CapturedNamedArguments, PrimitiveReference } from '@glimmer/runtime'; +import { Destroyable } from '@glimmer/util'; + +import { addChildView } from 'ember-views'; + +import Environment from '../environment'; +import { DynamicScope, Renderer } from '../renderer'; +import { RootReference } from '../utils/references'; +import AbstractComponentManager from './abstract'; +import DefinitionState from './definition-state'; + +export interface CustomComponentManagerDelegate { + version: 'string'; + create(options: { ComponentClass: T, args: {} }): T; + getContext(instance: T): Opaque; + update(instance: T, args: {}): void; + destroy?(instance: T): void; + didCreate?(instance: T): void; + didUpdate?(instance: T): void; + getView?(instance: T): any; +} + +export interface ComponentArguments { + positional: Opaque[]; + named: T; +} + +/** + The CustomComponentManager allows addons to provide custom component + implementations that integrate seamlessly into Ember. This is accomplished + through a delegate, registered with the custom component manager, which + implements a set of hooks that determine component behavior. + + To create a custom component manager, instantiate a new CustomComponentManager + class and pass the delegate as the first argument: + + ```js + let manager = new CustomComponentManager({ + // ...delegate implementation... + }); + ``` + + ## Delegate Hooks + + Throughout the lifecycle of a component, the component manager will invoke + delegate hooks that are responsible for surfacing those lifecycle changes to + the end developer. + + * `create()` - invoked when a new instance of a component should be created + * `update()` - invoked when the arguments passed to a component change + * `getContext()` - returns the object that should be +*/ +export default class CustomComponentManager extends AbstractComponentManager | null, DefinitionState> { + constructor(private delegate: CustomComponentManagerDelegate) { + super(); + } + + create(_env: Environment, definition: DefinitionState, args: Arguments, dynamicScope: DynamicScope): CustomComponentState { + const { delegate } = this; + const capturedArgs = args.named.capture(); + + const component = delegate.create({ + args: capturedArgs.value(), + ComponentClass: definition.ComponentClass as any as T + }); + + const { view: parentView } = dynamicScope; + + if (parentView !== null && parentView !== undefined) { + addChildView(parentView, component); + } + + dynamicScope.view = component; + + return new CustomComponentState(delegate, component, capturedArgs); + } + + update({ component, args }: CustomComponentState) { + this.delegate.update(component, args.value()); + } + + getContext(component: T) { + this.delegate.getContext(component); + } + + getLayout(state: DefinitionState) { + return { + handle: state.template.asLayout().compile(), + symbolTable: state.symbolTable + }; + } + + getSelf({ component }: CustomComponentState): PrimitiveReference | PathReference { + const context = this.delegate.getContext(component); + return new RootReference(context); + } + + getDestructor(state: CustomComponentState): Option { + return state; + } + + getCapabilities(_state: DefinitionState): ComponentCapabilities { + return { + dynamicLayout: false, + dynamicTag: false, + prepareArgs: false, + createArgs: true, + attributeHook: false, + elementHook: false + }; + } + + getTag({ args }: CustomComponentState): Tag { + return args.tag; + } + + didRenderLayout({ component }: CustomComponentState, _bounds: Bounds) { + const renderer = getRenderer(component); + renderer.register(component); + } +} + +/** + * Stores internal state about a component instance after it's been created. + */ +class CustomComponentState { + constructor( + public delegate: CustomComponentManagerDelegate, + public component: T, + public args: CapturedNamedArguments + ) {} + + destroy() { + const { delegate, component } = this; + + let renderer = getRenderer(component); + renderer.unregister(component); + + if (delegate.destroy) { delegate.destroy(component); } + } +} + +function getRenderer(component: {}): Renderer { + let renderer = component['renderer']; + if (!renderer) { throw new Error(`missing renderer for component ${component}`); } + return renderer as Renderer; +} \ No newline at end of file diff --git a/packages/ember-glimmer/lib/component.ts b/packages/ember-glimmer/lib/component.ts index 310e1a9d96f..40b69233b23 100644 --- a/packages/ember-glimmer/lib/component.ts +++ b/packages/ember-glimmer/lib/component.ts @@ -19,6 +19,7 @@ import { ViewMixin, ViewStateSupport, } from 'ember-views'; + import { RootReference, UPDATE } from './utils/references'; export const DIRTY_TAG = symbol('DIRTY_TAG'); @@ -914,4 +915,4 @@ Component.reopenClass({ positionalParams: [], }); -export default Component; +export default Component; \ No newline at end of file diff --git a/packages/ember-glimmer/lib/index.ts b/packages/ember-glimmer/lib/index.ts index 1b62aa38ee9..afcca671803 100644 --- a/packages/ember-glimmer/lib/index.ts +++ b/packages/ember-glimmer/lib/index.ts @@ -303,3 +303,5 @@ export { UpdatableReference, INVOKE } from './utils/references'; export { default as iterableFor } from './utils/iterable'; export { default as DebugStack } from './utils/debug-stack'; export { default as OutletView } from './views/outlet'; +export { default as CustomComponentManager } from './component-managers/custom'; +export { COMPONENT_MANAGER, componentManager } from './utils/custom-component-manager'; \ No newline at end of file diff --git a/packages/ember-glimmer/lib/renderer.ts b/packages/ember-glimmer/lib/renderer.ts index 4bcedf60fe3..6768da285c8 100644 --- a/packages/ember-glimmer/lib/renderer.ts +++ b/packages/ember-glimmer/lib/renderer.ts @@ -41,7 +41,7 @@ export type IBuilder = (env: Environment, cursor: Cursor) => ElementBuilder; export class DynamicScope implements GlimmerDynamicScope { constructor( - public view: Component | null, + public view: Component | {} | null, public outletState: VersionedPathReference, public rootOutletState?: VersionedPathReference) { } diff --git a/packages/ember-glimmer/lib/resolver.ts b/packages/ember-glimmer/lib/resolver.ts index 6c87e4ff44c..488c629e714 100644 --- a/packages/ember-glimmer/lib/resolver.ts +++ b/packages/ember-glimmer/lib/resolver.ts @@ -6,6 +6,7 @@ import { } from '@glimmer/interfaces'; import { LazyCompiler, Macros, PartialDefinition } from '@glimmer/opcode-compiler'; import { + ComponentManager, getDynamicVar, Helper, ModifierManager, @@ -20,9 +21,10 @@ import { lookupPartial, OwnedTemplateMeta, } from 'ember-views'; -import { EMBER_MODULE_UNIFICATION } from 'ember/features'; +import { EMBER_MODULE_UNIFICATION, GLIMMER_CUSTOM_COMPONENT_MANAGER } from 'ember/features'; import CompileTimeLookup from './compile-time-lookup'; import { CurlyComponentDefinition } from './component-managers/curly'; +import DefinitionState from './component-managers/definition-state'; import { TemplateOnlyComponentDefinition } from './component-managers/template-only'; import { isHelperFactory, isSimpleHelper } from './helper'; import { default as classHelper } from './helpers/-class'; @@ -46,6 +48,8 @@ import { mountHelper } from './syntax/mount'; import { outletHelper } from './syntax/outlet'; import { renderHelper } from './syntax/render'; import { Factory as TemplateFactory, Injections, OwnedTemplate } from './template'; +import ComponentStateBucket from './utils/curly-component-state-bucket'; +import getCustomComponentManager from './utils/custom-component-manager'; import { ClassBasedHelperReference, SimpleHelperReference } from './utils/references'; function instrumentationPayload(name: string) { @@ -293,11 +297,17 @@ export default class RuntimeResolver implements IRuntimeResolver | undefined; + + if (GLIMMER_CUSTOM_COMPONENT_MANAGER && component && component.class) { + manager = getCustomComponentManager(meta.owner, component.class); + } + let finalizer = _instrumentStart('render.getComponentDefinition', instrumentationPayload, name); let definition = (layout || component) ? new CurlyComponentDefinition( name, - undefined, + manager, component || meta.owner.factoryFor(P`component:-default`), null, layout diff --git a/packages/ember-glimmer/lib/utils/curly-component-state-bucket.ts b/packages/ember-glimmer/lib/utils/curly-component-state-bucket.ts index 56ecb9949e6..3548f30818d 100644 --- a/packages/ember-glimmer/lib/utils/curly-component-state-bucket.ts +++ b/packages/ember-glimmer/lib/utils/curly-component-state-bucket.ts @@ -13,7 +13,7 @@ export interface Component { elementId: string; tagName: string; isDestroying: boolean; - appendChild(view: Component): void; + appendChild(view: {}): void; trigger(event: string): void; destroy(): void; setProperties(props: { diff --git a/packages/ember-glimmer/lib/utils/custom-component-manager.ts b/packages/ember-glimmer/lib/utils/custom-component-manager.ts new file mode 100644 index 00000000000..bb8727c6b3b --- /dev/null +++ b/packages/ember-glimmer/lib/utils/custom-component-manager.ts @@ -0,0 +1,36 @@ +import { ComponentManager } from '@glimmer/runtime'; + +import { assert } from 'ember-debug'; +import { Owner, symbol } from 'ember-utils'; + +import DefinitionState from '../component-managers/definition-state'; +import ComponentStateBucket from '../utils/curly-component-state-bucket'; + +import { GLIMMER_CUSTOM_COMPONENT_MANAGER } from 'ember/features'; + +export const COMPONENT_MANAGER = symbol('COMPONENT_MANAGER'); + +export function componentManager(obj: any, managerId: String) { + if ('reopenClass' in obj) { + return obj.reopenClass({ + [COMPONENT_MANAGER]: managerId + }); + } + + obj[COMPONENT_MANAGER] = managerId; + return obj; +} + +export default function getCustomComponentManager(owner: Owner, obj: {}): ComponentManager | undefined { + if (!GLIMMER_CUSTOM_COMPONENT_MANAGER) { return; } + + if (!obj) { return; } + + let managerId = obj[COMPONENT_MANAGER]; + if (!managerId) { return; } + + let manager = owner.lookup(`component-manager:${managerId}`) as ComponentManager; + assert(`Could not find custom component manager '${managerId}' for ${obj}`, !!manager); + + return manager; +} \ No newline at end of file diff --git a/packages/ember-glimmer/tests/integration/custom-component-manager-test.js b/packages/ember-glimmer/tests/integration/custom-component-manager-test.js new file mode 100644 index 00000000000..0010cb9f3a7 --- /dev/null +++ b/packages/ember-glimmer/tests/integration/custom-component-manager-test.js @@ -0,0 +1,253 @@ +import { moduleFor, RenderingTest } from '../utils/test-case'; +import { strip } from '../utils/abstract-test-case'; +import { Component } from '../utils/helpers'; + +import { Object as EmberObject } from 'ember-runtime'; +import { set, setProperties, computed } from 'ember-metal'; +import { GLIMMER_CUSTOM_COMPONENT_MANAGER } from 'ember/features'; +import { componentManager, CustomComponentManager } from 'ember-glimmer'; +import { getChildViews } from 'ember-views'; +import { assign } from 'ember-utils'; + +const MANAGER_ID = 'test-custom'; + +const CustomComponent = componentManager(EmberObject.extend(), MANAGER_ID); + +if (GLIMMER_CUSTOM_COMPONENT_MANAGER) { + moduleFor('Components test: curly components with custom manager', class extends RenderingTest { + /* + * Helper to register a custom component manager. Provides a basic, default + * implementation of the custom component manager API, but can be overridden + * by passing custom hooks. + */ + registerCustomComponentManager(overrides = {}) { + let options = assign({ + version: '3.1', + create({ ComponentClass }) { + return ComponentClass.create(); + }, + + getContext(component) { + return component; + }, + + update() { + } + }, overrides); + + let manager = new CustomComponentManager(options); + + this.owner.register(`component-manager:${MANAGER_ID}`, manager, { singleton: true, instantiate: false }); + } + + // Renders a simple component with a custom component manager and verifies + // that properties from the component are accessible from the component's + // template. + ['@test it can render a basic component with custom component manager']() { + this.registerCustomComponentManager(); + + let ComponentClass = CustomComponent.extend({ + greeting: 'hello' + }); + + this.registerComponent('foo-bar', { + template: `

{{greeting}} world

`, + ComponentClass + }); + + this.render('{{foo-bar}}'); + + this.assertHTML(strip`

hello world

`); + } + + // Tests the custom component manager's ability to override template context + // by implementing the getContext hook. Test performs an initial render and + // updating render and verifies that output came from the custom context, + // not the component instance. + ['@test it can customize the template context']() { + let customContext = { + greeting: 'goodbye' + }; + + this.registerCustomComponentManager({ + getContext() { return customContext; } + }); + + let ComponentClass = CustomComponent.extend({ + greeting: 'hello', + count: 1234 + }); + + this.registerComponent('foo-bar', { + template: `

{{greeting}} world {{count}}

`, + ComponentClass + }); + + this.render('{{foo-bar}}'); + + this.assertHTML(strip`

goodbye world

`); + + this.runTask(() => set(customContext, 'greeting', 'sayonara')); + + this.assertHTML(strip`

sayonara world

`); + } + + ['@test it can set arguments on the component instance']() { + this.registerCustomComponentManager({ + create({ ComponentClass, args }) { + return ComponentClass.create({ args }); + } + }); + + let ComponentClass = CustomComponent.extend({ + salutation: computed('args.firstName', 'args.lastName', function() { + return this.get('args.firstName') + ' ' + this.get('args.lastName'); + }) + }); + + this.registerComponent('foo-bar', { + template: `

{{salutation}}

`, + ComponentClass + }); + + this.render('{{foo-bar firstName="Yehuda" lastName="Katz"}}'); + + this.assertHTML(strip`

Yehuda Katz

`); + } + + ['@test arguments are updated if they change']() { + this.registerCustomComponentManager({ + create({ ComponentClass, args }) { + return ComponentClass.create({ args }); + }, + + update(component, args) { + set(component, 'args', args); + } + }); + + let ComponentClass = CustomComponent.extend({ + salutation: computed('args.firstName', 'args.lastName', function() { + return this.get('args.firstName') + ' ' + this.get('args.lastName'); + }) + }); + + this.registerComponent('foo-bar', { + template: `

{{salutation}}

`, + ComponentClass + }); + + this.render('{{foo-bar firstName=firstName lastName=lastName}}', { + firstName: "Yehuda", + lastName: "Katz" + }); + + this.assertHTML(strip`

Yehuda Katz

`); + + this.runTask(() => setProperties(this.context, { + firstName: "Chad", lastName: "Hietala" + })); + + this.assertHTML(strip`

Chad Hietala

`); + } + + [`@test custom components appear in parent view's childViews array`](assert) { + this.registerCustomComponentManager(); + + let ComponentClass = CustomComponent.extend({ + isCustomComponent: true + }); + + this.registerComponent('turbo-component', { + template: `

turbo

`, + ComponentClass + }); + + this.registerComponent('curly-component', { + template: `
curly
`, + ComponentClass: Component.extend({ + isClassicComponent: true + }) + }); + + this.render('{{#if showTurbo}}{{turbo-component}}{{/if}} {{curly-component}}', { + showTurbo: true + }); + + let { childViews } = this.context; + + assert.equal(childViews.length, 2, 'root component has two child views'); + assert.ok(childViews[0].isCustomComponent, 'first child view is custom component'); + assert.ok(childViews[1].isClassicComponent, 'second child view is classic component'); + + this.runTask(() => set(this.context, 'showTurbo', false)); + + // childViews array is not live and must be re-fetched after changes + childViews = this.context.childViews; + + assert.equal(childViews.length, 1, 'turbo component is removed from parent\'s child views array'); + assert.ok(childViews[0].isClassicComponent, 'first child view is classic component'); + + this.runTask(() => set(this.context, 'showTurbo', true)); + + childViews = this.context.childViews; + assert.equal(childViews.length, 2, 'root component has two child views'); + assert.ok(childViews[0].isClassicComponent, 'first child view is classic component'); + assert.ok(childViews[1].isCustomComponent, 'second child view is custom component'); + } + + ['@test can invoke classic components in custom components'](assert) { + this.registerCustomComponentManager(); + + let ComponentClass = CustomComponent.extend({ + isCustomComponent: true + }); + + this.registerComponent('turbo-component', { + template: `

turbo

{{curly-component}}`, + ComponentClass + }); + + let classicComponent; + + this.registerComponent('curly-component', { + template: `
curly
`, + ComponentClass: Component.extend({ + init() { + this._super(...arguments); + classicComponent = this; + }, + + isClassicComponent: true + }) + }); + + this.render('{{turbo-component}}'); + + this.assertElement(this.firstChild, { + tagName: 'P', + content: 'turbo' + }); + + this.assertComponentElement(this.firstChild.nextSibling, { + tagName: 'DIV', + content: '
curly
' + }); + + let { childViews } = this.context; + + assert.equal(childViews.length, 1, 'root component has one child view'); + assert.ok(childViews[0].isCustomComponent, 'root child view is custom component'); + + let customComponent = childViews[0]; + + assert.strictEqual(customComponent.childViews, undefined, 'custom component does not have childViews property'); + + childViews = getChildViews(customComponent); + assert.equal(childViews.length, 1, 'custom component has one child view'); + assert.ok(childViews[0].isClassicComponent, 'custom component child view is classic component'); + + assert.ok(classicComponent.parentView.isCustomComponent, `classic component's parentView is custom component`); + } + }); +} diff --git a/packages/ember-views/lib/index.d.ts b/packages/ember-views/lib/index.d.ts index 025c953f870..5135c0262c8 100644 --- a/packages/ember-views/lib/index.d.ts +++ b/packages/ember-views/lib/index.d.ts @@ -24,6 +24,8 @@ export const TextSupport: any; export function getViewElement(view: Opaque): Simple.Element; export function setViewElement(view: Opaque, element: Simple.Element | null): void; +export function addChildView(parent: Opaque, child: Opaque): void; + export function isSimpleClick(event: Event): boolean; export function constructStyleDeprecationMessage(affectedStyle: any): string; diff --git a/packages/ember-views/lib/index.js b/packages/ember-views/lib/index.js index d4c9761e817..068c4bbd451 100644 --- a/packages/ember-views/lib/index.js +++ b/packages/ember-views/lib/index.js @@ -1,6 +1,7 @@ export { default as jQuery, jQueryDisabled } from './system/jquery'; export { + addChildView, isSimpleClick, getViewBounds, getViewClientRects, diff --git a/packages/ember-views/lib/mixins/child_views_support.js b/packages/ember-views/lib/mixins/child_views_support.js index b909a896916..0925a2fbcc0 100644 --- a/packages/ember-views/lib/mixins/child_views_support.js +++ b/packages/ember-views/lib/mixins/child_views_support.js @@ -1,23 +1,16 @@ /** @module ember */ -import { getOwner, setOwner } from 'ember-utils'; import { Mixin, descriptor } from 'ember-metal'; import { - initChildViews, getChildViews, addChildView } from '../system/utils'; export default Mixin.create({ - init() { - this._super(...arguments); - initChildViews(this); - }, - /** Array of child views. You should never edit this array directly. @@ -35,13 +28,6 @@ export default Mixin.create({ }), appendChild(view) { - this.linkChild(view); addChildView(this, view); - }, - - linkChild(instance) { - if (!getOwner(instance)) { - setOwner(instance, getOwner(this)); - } } }); diff --git a/packages/ember-views/lib/system/utils.js b/packages/ember-views/lib/system/utils.js index 5217f2413b1..79bc51bf532 100644 --- a/packages/ember-views/lib/system/utils.js +++ b/packages/ember-views/lib/system/utils.js @@ -74,7 +74,7 @@ export function setViewElement(view, element) { return view[VIEW_ELEMENT] = element; } -const CHILD_VIEW_IDS = symbol('CHILD_VIEW_IDS'); +const CHILD_VIEW_IDS = new WeakMap(); /** @private @@ -88,27 +88,35 @@ export function getChildViews(view) { } export function initChildViews(view) { - view[CHILD_VIEW_IDS] = []; + let childViews = []; + CHILD_VIEW_IDS.set(view, childViews); + return childViews; } export function addChildView(parent, child) { - parent[CHILD_VIEW_IDS].push(getViewId(child)); + let childViews = CHILD_VIEW_IDS.get(parent); + if (childViews === undefined) { + childViews = initChildViews(parent); + } + + childViews.push(getViewId(child)); } export function collectChildViews(view, registry) { let ids = []; let views = []; + let childViews = CHILD_VIEW_IDS.get(view); - view[CHILD_VIEW_IDS].forEach(id => { - let view = registry[id]; + if (childViews) { + childViews.forEach(id => { + let view = registry[id]; - if (view && !view.isDestroying && !view.isDestroyed && ids.indexOf(id) === -1) { - ids.push(id); - views.push(view); - } - }); - - view[CHILD_VIEW_IDS] = ids; + if (view && !view.isDestroying && !view.isDestroyed && ids.indexOf(id) === -1) { + ids.push(id); + views.push(view); + } + }); + } return views; } diff --git a/packages/ember/lib/index.js b/packages/ember/lib/index.js index 5f680b65904..700798506ca 100644 --- a/packages/ember/lib/index.js +++ b/packages/ember/lib/index.js @@ -416,19 +416,20 @@ Object.defineProperty(Ember, 'BOOTED', { }); import { + Checkbox, Component, + componentManager, + escapeExpression, + getTemplates, Helper, helper, - Checkbox, - TextField, - TextArea, - LinkComponent, htmlSafe, - template, - escapeExpression, isHTMLSafe, - getTemplates, - setTemplates + LinkComponent, + setTemplates, + template, + TextField, + TextArea } from 'ember-glimmer'; Ember.Component = Component; @@ -439,6 +440,11 @@ Ember.TextField = TextField; Ember.TextArea = TextArea; Ember.LinkComponent = LinkComponent; +Object.defineProperty(Ember, '_setComponentManager', { + enumerable: false, + get() { return componentManager; } +}); + if (ENV.EXTEND_PROTOTYPES.String) { String.prototype.htmlSafe = function() { return htmlSafe(this);