import { relativeToFile } from 'aurelia-path'; import { metadata } from 'aurelia-metadata'; import * as LogManager from 'aurelia-logging'; import { BindableProperty } from './bindable-property'; import { HtmlBehaviorResource } from './html-behavior'; import { BindingLanguage } from './binding-language'; import { BehaviorInstruction, ViewCompileInstruction, ViewCreateInstruction } from './instructions'; import { Container } from 'aurelia-dependency-injection'; import { _hyphenate } from './util'; import { ValueConverterResource, BindingBehaviorResource, camelCase, bindingMode } from 'aurelia-binding'; import { ViewEngineHooksResource } from './view-engine-hooks-resource'; import { ViewResourceType } from './type-extension'; import { ViewFactory } from './view-factory'; import { ViewCompiler } from './view-compiler'; import { View } from './view'; function register(lookup, name, resource, type) { if (!name) { return; } let existing = lookup[name]; if (existing) { if (existing !== resource) { throw new Error(`Attempted to register ${type} when one with the same name already exists. Name: ${name}.`); } return; } lookup[name] = resource; } /** * View engine hooks that enable a view resource to provide custom processing during the compilation or creation of a view. */ export interface ViewEngineHooks { /** * Invoked before a template is compiled. * @param content The DocumentFragment to compile. * @param resources The resources to compile the view against. * @param instruction The compilation instruction associated with the compilation process. */ beforeCompile?: (content: DocumentFragment, resources: ViewResources, instruction: ViewCompileInstruction) => void; /** * Invoked after a template is compiled. * @param viewFactory The view factory that was produced from the compilation process. */ afterCompile?: (viewFactory: ViewFactory) => void; /** * Invoked before a view is created. * @param viewFactory The view factory that will be used to create the view. * @param container The DI container used during view creation. * @param content The cloned document fragment representing the view. * @param instruction The view creation instruction associated with this creation process. */ beforeCreate?: (viewFactory: ViewFactory, container: Container, content: DocumentFragment, instruction: ViewCreateInstruction) => void; /** * Invoked after a view is created. * @param view The view that was created by the factory. */ afterCreate?: (view: View) => void; /** * Invoked after the bindingContext and overrideContext are configured on the view but before the view is bound. * @param view The view that was created by the factory. */ beforeBind?: (view: View) => void; /** * Invoked before the view is unbind. The bindingContext and overrideContext are still available on the view. * @param view The view that was created by the factory. */ beforeUnbind?: (view: View) => void; } export interface IBindablePropertyConfig { /** * The name of the property. */ name?: string; attribute?: string; /** * The default binding mode of the property. If given string, will use to lookup */ defaultBindingMode?: bindingMode | 'oneTime' | 'oneWay' | 'twoWay' | 'fromView' | 'toView'; /** * The name of a view model method to invoke when the property is updated. */ changeHandler?: string; /** * A default value for the property. */ defaultValue?: any; /** * Designates the property as the default bindable property among all the other bindable properties when used in a custom attribute with multiple bindable properties. */ primaryProperty?: boolean; // For compatibility and future extension [key: string]: any; } export type IStaticResource = Function & { $resource?: string | IStaticResourceConfig | (() => string | IStaticResourceConfig); }; export interface IStaticResourceConfig { /** * Resource type of this class, omit equals to custom element */ type?: 'element' | 'attribute' | 'valueConverter' | 'bindingBehavior' | 'viewEngineHooks'; /** * Name of this resource. Reccommended to explicitly set to works better with minifier */ name?: string; /** * Used to tell if a custom attribute is a template controller */ templateController?: boolean; /** * Used to set default binding mode of default custom attribute view model "value" property */ defaultBindingMode?: bindingMode | 'oneTime' | 'oneWay' | 'twoWay' | 'fromView' | 'toView'; /** * Flags a custom attribute has dynamic options */ hasDynamicOptions?: boolean; /** * Flag if this custom element uses native shadow dom instead of emulation */ usesShadowDOM?: boolean; /** * Options that will be used if the element is flagged with usesShadowDOM */ shadowDOMOptions?: ShadowRootInit; /** * Flag a custom element as containerless. Which will remove their render target */ containerless?: boolean; /** * Custom processing of the attributes on an element before the framework inspects them. */ processAttributes?: (viewCompiler: ViewCompiler, resources: ViewResources, node: Element, attributes: NamedNodeMap, elementInstruction: BehaviorInstruction) => void; /** * Enables custom processing of the content that is places inside the custom element by its consumer. * Pass a boolean to direct the template compiler to not process * the content placed inside this element. Alternatively, pass a function which * can provide custom processing of the content. This function should then return * a boolean indicating whether the compiler should also process the content. */ processContent?: (viewCompiler: ViewCompiler, resources: ViewResources, node: Element, instruction: BehaviorInstruction) => boolean; /** * List of bindable properties of this custom element / custom attribute, by name or full config object */ bindables?: Array; } export function validateBehaviorName(name: string, type: string) { if (/[A-Z]/.test(name)) { let newName = _hyphenate(name); LogManager .getLogger('templating') .warn(`'${name}' is not a valid ${type} name and has been converted to '${newName}'. Upper-case letters are not allowed because the DOM is not case-sensitive.`); return newName; } return name; } const conventionMark = '__au_resource__'; /** * Represents a collection of resources used during the compilation of a view. * Will optinally add information to an existing HtmlBehaviorResource if given */ export class ViewResources { /** @internal */ private parent: ViewResources; /** @internal */ private hasParent: boolean; /** @internal */ viewUrl: string; /** @internal */ private lookupFunctions: { valueConverters: any; bindingBehaviors: any; }; /** @internal */ private attributes: any; /** @internal */ private elements: any; /** @internal */ private valueConverters: any; /** @internal */ private bindingBehaviors: any; /** @internal */ private attributeMap: any; /** @internal */ private values: any; /** @internal */ beforeCompile: boolean; /** @internal */ afterCompile: boolean; /** @internal */ beforeCreate: boolean; /** @internal */ afterCreate: boolean; /** @internal */ beforeBind: boolean; /** @internal */ beforeUnbind: boolean; /** * Checks whether the provided class contains any resource conventions * @param target Target class to extract metadata based on convention * @param existing If supplied, all custom element / attribute metadata extracted from convention will be apply to this instance */ static convention(target: IStaticResource, existing?: HtmlBehaviorResource): ViewResourceType { let resource; // Use a simple string to mark that an HtmlBehaviorResource instance // has been applied all resource information from its target view model class // to prevent subsequence call re initialization all info again if (existing && conventionMark in existing) { return existing; } if ('$resource' in target) { let config = target.$resource; // 1. check if resource config is a string if (typeof config === 'string') { // it's a custom element, with name is the resource variable // static $resource = 'my-element' resource = existing || new HtmlBehaviorResource(); resource[conventionMark] = true; if (!resource.elementName) { // if element name was not specified before resource.elementName = validateBehaviorName(config, 'custom element'); } } else { // 2. if static config is not a string, normalize into an config object if (typeof config === 'function') { // static $resource() { } config = config.call(target); } if (typeof config === 'string') { // static $resource() { return 'my-custom-element-name' } // though rare case, still needs to handle properly config = { name: config }; } // after normalization, copy to another obj // as the config could come from a static field, which subject to later reuse // it shouldn't be modified config = Object.assign({}, config); // no type specified = custom element let resourceType = config.type || 'element'; // cannot do name = config.name || target.name // depends on resource type, it may need to use different strategies to normalize name let name = config.name; switch (resourceType) { // eslint-disable-line default-case case 'element': case 'attribute': // if a metadata is supplied, use it resource = existing || new HtmlBehaviorResource(); resource[conventionMark] = true; if (resourceType === 'element') { // if element name was defined before applying convention here // it's a result from `@customElement` call (or manual modification) // need not to redefine name // otherwise, fall into following if if (!resource.elementName) { resource.elementName = name ? validateBehaviorName(name, 'custom element') : _hyphenate(target.name); } } else { // attribute name was defined before applying convention here // it's a result from `@customAttribute` call (or manual modification) // need not to redefine name // otherwise, fall into following if if (!resource.attributeName) { resource.attributeName = name ? validateBehaviorName(name, 'custom attribute') : _hyphenate(target.name); } } if ('templateController' in config) { // map templateController to liftsContent (config as any).liftsContent = config.templateController; delete config.templateController; } if ('defaultBindingMode' in config && resource.attributeDefaultBindingMode !== undefined) { // map defaultBindingMode to attributeDefaultBinding mode // custom element doesn't have default binding mode (config as any).attributeDefaultBindingMode = config.defaultBindingMode; delete config.defaultBindingMode; } // not bringing over the name. delete config.name; // just copy over. Devs are responsible for what specified in the config Object.assign(resource, config); break; case 'valueConverter': resource = new ValueConverterResource(camelCase(name || target.name)); break; case 'bindingBehavior': resource = new BindingBehaviorResource(camelCase(name || target.name)); break; case 'viewEngineHooks': resource = new ViewEngineHooksResource(); break; } } if (resource instanceof HtmlBehaviorResource) { // check for bindable registration // This will concat bindables specified in static field / method with bindables specified via decorators // Which means if `name` is specified in both decorator and static config, it will be duplicated here // though it will finally resolves to only 1 `name` attribute // Will not break if it's done in that way but probably only happenned in inheritance scenarios. let bindables = typeof config === 'string' ? undefined : config.bindables; let currentProps = resource.properties; if (Array.isArray(bindables)) { for (let i = 0, ii = bindables.length; ii > i; ++i) { let prop = bindables[i]; if (!prop || (typeof prop !== 'string' && !prop.name)) { throw new Error(`Invalid bindable property at "${i}" for class "${target.name}". Expected either a string or an object with "name" property.`); } let newProp = new BindableProperty(prop); // Bindable properties defined in $resource convention // shouldn't override existing prop with the same name // as they could be explicitly defined via decorator, thus more trust worthy ? let existed = false; for (let j = 0, jj = currentProps.length; jj > j; ++j) { if (currentProps[j].name === newProp.name) { existed = true; break; } } if (existed) { continue; } newProp.registerWith(target, resource); } } } } return resource; } /** * A custom binding language used in the view. */ bindingLanguage = null; /** * Creates an instance of ViewResources. * @param parent The parent resources. This resources can override them, but if a resource is not found, it will be looked up in the parent. * @param viewUrl The url of the view to which these resources apply. */ constructor(parent?: ViewResources, viewUrl?: string) { this.parent = parent || null; this.hasParent = this.parent !== null; this.viewUrl = viewUrl || ''; this.lookupFunctions = { valueConverters: this.getValueConverter.bind(this), bindingBehaviors: this.getBindingBehavior.bind(this) }; this.attributes = Object.create(null); this.elements = Object.create(null); this.valueConverters = Object.create(null); this.bindingBehaviors = Object.create(null); this.attributeMap = Object.create(null); this.values = Object.create(null); this.beforeCompile = this.afterCompile = this.beforeCreate = this.afterCreate = this.beforeBind = this.beforeUnbind = false; } /** @internal */ _tryAddHook(obj, name) { if (typeof obj[name] === 'function') { let func = obj[name].bind(obj); let counter = 1; let callbackName; while (this[callbackName = name + counter.toString()] !== undefined) { counter++; } this[name] = true; this[callbackName] = func; } } /** @internal */ _invokeHook(name: string, one?, two?, three?, four?) { if (this.hasParent) { this.parent._invokeHook(name, one, two, three, four); } if (this[name]) { this[name + '1'](one, two, three, four); let callbackName = name + '2'; if (this[callbackName]) { this[callbackName](one, two, three, four); callbackName = name + '3'; if (this[callbackName]) { this[callbackName](one, two, three, four); let counter = 4; while (this[callbackName = name + counter.toString()] !== undefined) { this[callbackName](one, two, three, four); counter++; } } } } } /** * Registers view engine hooks for the view. * @param hooks The hooks to register. */ registerViewEngineHooks(hooks: ViewEngineHooks): void { this._tryAddHook(hooks, 'beforeCompile'); this._tryAddHook(hooks, 'afterCompile'); this._tryAddHook(hooks, 'beforeCreate'); this._tryAddHook(hooks, 'afterCreate'); this._tryAddHook(hooks, 'beforeBind'); this._tryAddHook(hooks, 'beforeUnbind'); } /** * Gets the binding language associated with these resources, or return the provided fallback implementation. * @param bindingLanguageFallback The fallback binding language implementation to use if no binding language is configured locally. * @return The binding language. */ getBindingLanguage(bindingLanguageFallback: BindingLanguage): BindingLanguage { return this.bindingLanguage || (this.bindingLanguage = bindingLanguageFallback); } /** * Patches an immediate parent into the view resource resolution hierarchy. * @param newParent The new parent resources to patch in. */ patchInParent(newParent: ViewResources): void { let originalParent = this.parent; this.parent = newParent || null; this.hasParent = this.parent !== null; if (newParent.parent === null) { newParent.parent = originalParent; newParent.hasParent = originalParent !== null; } } /** * Maps a path relative to the associated view's origin. * @param path The relative path. * @return The calcualted path. */ relativeToView(path: string): string { return relativeToFile(path, this.viewUrl); } /** * Registers an HTML element. * @param tagName The name of the custom element. * @param behavior The behavior of the element. */ registerElement(tagName: string, behavior: HtmlBehaviorResource): void { register(this.elements, tagName, behavior, 'an Element'); } /** * Gets an HTML element behavior. * @param tagName The tag name to search for. * @return The HtmlBehaviorResource for the tag name or null. */ getElement(tagName: string): HtmlBehaviorResource { return this.elements[tagName] || (this.hasParent ? this.parent.getElement(tagName) : null); } /** * Gets the known attribute name based on the local attribute name. * @param attribute The local attribute name to lookup. * @return The known name. */ mapAttribute(attribute: string): string { return this.attributeMap[attribute] || (this.hasParent ? this.parent.mapAttribute(attribute) : null); } /** * Registers an HTML attribute. * @param attribute The name of the attribute. * @param behavior The behavior of the attribute. * @param knownAttribute The well-known name of the attribute (in lieu of the local name). */ registerAttribute(attribute: string, behavior: HtmlBehaviorResource, knownAttribute: string): void { this.attributeMap[attribute] = knownAttribute; register(this.attributes, attribute, behavior, 'an Attribute'); } /** * Gets an HTML attribute behavior. * @param attribute The name of the attribute to lookup. * @return The HtmlBehaviorResource for the attribute or null. */ getAttribute(attribute: string): HtmlBehaviorResource { return this.attributes[attribute] || (this.hasParent ? this.parent.getAttribute(attribute) : null); } /** * Registers a value converter. * @param name The name of the value converter. * @param valueConverter The value converter instance. */ registerValueConverter(name: string, valueConverter: Object): void { register(this.valueConverters, name, valueConverter, 'a ValueConverter'); } /** * Gets a value converter. * @param name The name of the value converter. * @return The value converter instance. */ getValueConverter(name: string): Object { return this.valueConverters[name] || (this.hasParent ? this.parent.getValueConverter(name) : null); } /** * Registers a binding behavior. * @param name The name of the binding behavior. * @param bindingBehavior The binding behavior instance. */ registerBindingBehavior(name: string, bindingBehavior: Object): void { register(this.bindingBehaviors, name, bindingBehavior, 'a BindingBehavior'); } /** * Gets a binding behavior. * @param name The name of the binding behavior. * @return The binding behavior instance. */ getBindingBehavior(name: string): Object { return this.bindingBehaviors[name] || (this.hasParent ? this.parent.getBindingBehavior(name) : null); } /** * Registers a value. * @param name The name of the value. * @param value The value. */ registerValue(name: string, value: any): void { register(this.values, name, value, 'a value'); } /** * Gets a value. * @param name The name of the value. * @return The value. */ getValue(name: string): any { return this.values[name] || (this.hasParent ? this.parent.getValue(name) : null); } /** * Not supported for public use. Can be changed without warning. * * Auto register a resources based on its metadata or convention * Will fallback to custom element if no metadata found and all conventions fail * @param container * @param impl */ autoRegister(container: Container, impl: Function): ViewResourceType { let resourceTypeMeta = metadata.getOwn(metadata.resource, impl) as ViewResourceType; if (resourceTypeMeta) { if (resourceTypeMeta instanceof HtmlBehaviorResource) { // first use static resource ViewResources.convention(impl, resourceTypeMeta); // then fallback to traditional convention if (resourceTypeMeta.attributeName === null && resourceTypeMeta.elementName === null) { //no customeElement or customAttribute but behavior added by other metadata HtmlBehaviorResource.convention(impl.name, resourceTypeMeta); } if (resourceTypeMeta.attributeName === null && resourceTypeMeta.elementName === null) { //no convention and no customeElement or customAttribute but behavior added by other metadata resourceTypeMeta.elementName = _hyphenate(impl.name); } } } else { resourceTypeMeta = ViewResources.convention(impl) || HtmlBehaviorResource.convention(impl.name) || ValueConverterResource.convention(impl.name) || BindingBehaviorResource.convention(impl.name) || ViewEngineHooksResource.convention(impl.name); if (!resourceTypeMeta) { // doesn't match any convention, and is an exported value => custom element resourceTypeMeta = new HtmlBehaviorResource(); (resourceTypeMeta as HtmlBehaviorResource).elementName = _hyphenate(impl.name); } metadata.define(metadata.resource, resourceTypeMeta, impl); } resourceTypeMeta.initialize(container, impl); resourceTypeMeta.register(this, undefined); return resourceTypeMeta; } }