/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ import * as html from '../ml_parser/ast'; import { replaceNgsp } from '../ml_parser/html_whitespaces'; import { isNgTemplate } from '../ml_parser/tags'; import { ParseError, ParseErrorLevel, ParseSourceSpan } from '../parse_util'; import { isStyleUrlResolvable } from '../style_url_resolver'; import { PreparsedElementType, preparseElement } from '../template_parser/template_preparser'; import * as t from './r3_ast'; import { createDeferredBlock } from './r3_deferred_blocks'; import { I18N_ICU_VAR_PREFIX, isI18nRootNode } from './view/i18n/util'; const BIND_NAME_REGEXP = /^(?:(bind-)|(let-)|(ref-|#)|(on-)|(bindon-)|(@))(.*)$/; // Group 1 = "bind-" const KW_BIND_IDX = 1; // Group 2 = "let-" const KW_LET_IDX = 2; // Group 3 = "ref-/#" const KW_REF_IDX = 3; // Group 4 = "on-" const KW_ON_IDX = 4; // Group 5 = "bindon-" const KW_BINDON_IDX = 5; // Group 6 = "@" const KW_AT_IDX = 6; // Group 7 = the identifier after "bind-", "let-", "ref-/#", "on-", "bindon-" or "@" const IDENT_KW_IDX = 7; const BINDING_DELIMS = { BANANA_BOX: { start: '[(', end: ')]' }, PROPERTY: { start: '[', end: ']' }, EVENT: { start: '(', end: ')' }, }; const TEMPLATE_ATTR_PREFIX = '*'; export function htmlAstToRender3Ast(htmlNodes, bindingParser, options) { const transformer = new HtmlAstToIvyAst(bindingParser, options); const ivyNodes = html.visitAll(transformer, htmlNodes); // Errors might originate in either the binding parser or the html to ivy transformer const allErrors = bindingParser.errors.concat(transformer.errors); const result = { nodes: ivyNodes, errors: allErrors, styleUrls: transformer.styleUrls, styles: transformer.styles, ngContentSelectors: transformer.ngContentSelectors }; if (options.collectCommentNodes) { result.commentNodes = transformer.commentNodes; } return result; } class HtmlAstToIvyAst { constructor(bindingParser, options) { this.bindingParser = bindingParser; this.options = options; this.errors = []; this.styles = []; this.styleUrls = []; this.ngContentSelectors = []; // This array will be populated if `Render3ParseOptions['collectCommentNodes']` is true this.commentNodes = []; this.inI18nBlock = false; } // HTML visitor visitElement(element) { const isI18nRootElement = isI18nRootNode(element.i18n); if (isI18nRootElement) { if (this.inI18nBlock) { this.reportError('Cannot mark an element as translatable inside of a translatable section. Please remove the nested i18n marker.', element.sourceSpan); } this.inI18nBlock = true; } const preparsedElement = preparseElement(element); if (preparsedElement.type === PreparsedElementType.SCRIPT) { return null; } else if (preparsedElement.type === PreparsedElementType.STYLE) { const contents = textContents(element); if (contents !== null) { this.styles.push(contents); } return null; } else if (preparsedElement.type === PreparsedElementType.STYLESHEET && isStyleUrlResolvable(preparsedElement.hrefAttr)) { this.styleUrls.push(preparsedElement.hrefAttr); return null; } // Whether the element is a `` const isTemplateElement = isNgTemplate(element.name); const parsedProperties = []; const boundEvents = []; const variables = []; const references = []; const attributes = []; const i18nAttrsMeta = {}; const templateParsedProperties = []; const templateVariables = []; // Whether the element has any *-attribute let elementHasInlineTemplate = false; for (const attribute of element.attrs) { let hasBinding = false; const normalizedName = normalizeAttributeName(attribute.name); // `*attr` defines template bindings let isTemplateBinding = false; if (attribute.i18n) { i18nAttrsMeta[attribute.name] = attribute.i18n; } if (normalizedName.startsWith(TEMPLATE_ATTR_PREFIX)) { // *-attributes if (elementHasInlineTemplate) { this.reportError(`Can't have multiple template bindings on one element. Use only one attribute prefixed with *`, attribute.sourceSpan); } isTemplateBinding = true; elementHasInlineTemplate = true; const templateValue = attribute.value; const templateKey = normalizedName.substring(TEMPLATE_ATTR_PREFIX.length); const parsedVariables = []; const absoluteValueOffset = attribute.valueSpan ? attribute.valueSpan.start.offset : // If there is no value span the attribute does not have a value, like `attr` in //`
`. In this case, point to one character beyond the last character of // the attribute name. attribute.sourceSpan.start.offset + attribute.name.length; this.bindingParser.parseInlineTemplateBinding(templateKey, templateValue, attribute.sourceSpan, absoluteValueOffset, [], templateParsedProperties, parsedVariables, true /* isIvyAst */); templateVariables.push(...parsedVariables.map(v => new t.Variable(v.name, v.value, v.sourceSpan, v.keySpan, v.valueSpan))); } else { // Check for variables, events, property bindings, interpolation hasBinding = this.parseAttribute(isTemplateElement, attribute, [], parsedProperties, boundEvents, variables, references); } if (!hasBinding && !isTemplateBinding) { // don't include the bindings as attributes as well in the AST attributes.push(this.visitAttribute(attribute)); } } let children; if (preparsedElement.nonBindable) { // The `NonBindableVisitor` may need to return an array of nodes for block groups so we need // to flatten the array here. Avoid doing this for the `HtmlAstToIvyAst` since `flat` creates // a new array. children = html.visitAll(NON_BINDABLE_VISITOR, element.children).flat(Infinity); } else { children = html.visitAll(this, element.children); } let parsedElement; if (preparsedElement.type === PreparsedElementType.NG_CONTENT) { // `` if (element.children && !element.children.every((node) => isEmptyTextNode(node) || isCommentNode(node))) { this.reportError(` element cannot have content.`, element.sourceSpan); } const selector = preparsedElement.selectAttr; const attrs = element.attrs.map(attr => this.visitAttribute(attr)); parsedElement = new t.Content(selector, attrs, element.sourceSpan, element.i18n); this.ngContentSelectors.push(selector); } else if (isTemplateElement) { // `` const attrs = this.extractAttributes(element.name, parsedProperties, i18nAttrsMeta); parsedElement = new t.Template(element.name, attributes, attrs.bound, boundEvents, [ /* no template attributes */], children, references, variables, element.sourceSpan, element.startSourceSpan, element.endSourceSpan, element.i18n); } else { const attrs = this.extractAttributes(element.name, parsedProperties, i18nAttrsMeta); parsedElement = new t.Element(element.name, attributes, attrs.bound, boundEvents, children, references, element.sourceSpan, element.startSourceSpan, element.endSourceSpan, element.i18n); } if (elementHasInlineTemplate) { // If this node is an inline-template (e.g. has *ngFor) then we need to create a template // node that contains this node. // Moreover, if the node is an element, then we need to hoist its attributes to the template // node for matching against content projection selectors. const attrs = this.extractAttributes('ng-template', templateParsedProperties, i18nAttrsMeta); const templateAttrs = []; attrs.literal.forEach(attr => templateAttrs.push(attr)); attrs.bound.forEach(attr => templateAttrs.push(attr)); const hoistedAttrs = parsedElement instanceof t.Element ? { attributes: parsedElement.attributes, inputs: parsedElement.inputs, outputs: parsedElement.outputs, } : { attributes: [], inputs: [], outputs: [] }; // For s with structural directives on them, avoid passing i18n information to // the wrapping template to prevent unnecessary i18n instructions from being generated. The // necessary i18n meta information will be extracted from child elements. const i18n = isTemplateElement && isI18nRootElement ? undefined : element.i18n; const name = parsedElement instanceof t.Template ? null : parsedElement.name; parsedElement = new t.Template(name, hoistedAttrs.attributes, hoistedAttrs.inputs, hoistedAttrs.outputs, templateAttrs, [parsedElement], [ /* no references */], templateVariables, element.sourceSpan, element.startSourceSpan, element.endSourceSpan, i18n); } if (isI18nRootElement) { this.inI18nBlock = false; } return parsedElement; } visitAttribute(attribute) { return new t.TextAttribute(attribute.name, attribute.value, attribute.sourceSpan, attribute.keySpan, attribute.valueSpan, attribute.i18n); } visitText(text) { return this._visitTextWithInterpolation(text.value, text.sourceSpan, text.tokens, text.i18n); } visitExpansion(expansion) { if (!expansion.i18n) { // do not generate Icu in case it was created // outside of i18n block in a template return null; } if (!isI18nRootNode(expansion.i18n)) { throw new Error(`Invalid type "${expansion.i18n.constructor}" for "i18n" property of ${expansion.sourceSpan.toString()}. Expected a "Message"`); } const message = expansion.i18n; const vars = {}; const placeholders = {}; // extract VARs from ICUs - we process them separately while // assembling resulting message via goog.getMsg function, since // we need to pass them to top-level goog.getMsg call Object.keys(message.placeholders).forEach(key => { const value = message.placeholders[key]; if (key.startsWith(I18N_ICU_VAR_PREFIX)) { // Currently when the `plural` or `select` keywords in an ICU contain trailing spaces (e.g. // `{count, select , ...}`), these spaces are also included into the key names in ICU vars // (e.g. "VAR_SELECT "). These trailing spaces are not desirable, since they will later be // converted into `_` symbols while normalizing placeholder names, which might lead to // mismatches at runtime (i.e. placeholder will not be replaced with the correct value). const formattedKey = key.trim(); const ast = this.bindingParser.parseInterpolationExpression(value.text, value.sourceSpan); vars[formattedKey] = new t.BoundText(ast, value.sourceSpan); } else { placeholders[key] = this._visitTextWithInterpolation(value.text, value.sourceSpan, null); } }); return new t.Icu(vars, placeholders, expansion.sourceSpan, message); } visitExpansionCase(expansionCase) { return null; } visitComment(comment) { if (this.options.collectCommentNodes) { this.commentNodes.push(new t.Comment(comment.value || '', comment.sourceSpan)); } return null; } visitBlockGroup(group, context) { const primaryBlock = group.blocks[0]; // The HTML parser ensures that we don't hit this case, but we have an assertion just in case. if (!primaryBlock) { this.reportError('Block group must have at least one block.', group.sourceSpan); return null; } if (primaryBlock.name === 'defer' && this.options.enabledBlockTypes.has(primaryBlock.name)) { const { node, errors } = createDeferredBlock(group, this, this.bindingParser); this.errors.push(...errors); return node; } this.reportError(`Unrecognized block "${primaryBlock.name}".`, primaryBlock.sourceSpan); return null; } visitBlock(block, context) { } visitBlockParameter(parameter, context) { } // convert view engine `ParsedProperty` to a format suitable for IVY extractAttributes(elementName, properties, i18nPropsMeta) { const bound = []; const literal = []; properties.forEach(prop => { const i18n = i18nPropsMeta[prop.name]; if (prop.isLiteral) { literal.push(new t.TextAttribute(prop.name, prop.expression.source || '', prop.sourceSpan, prop.keySpan, prop.valueSpan, i18n)); } else { // Note that validation is skipped and property mapping is disabled // due to the fact that we need to make sure a given prop is not an // input of a directive and directive matching happens at runtime. const bep = this.bindingParser.createBoundElementProperty(elementName, prop, /* skipValidation */ true, /* mapPropertyName */ false); bound.push(t.BoundAttribute.fromBoundElementProperty(bep, i18n)); } }); return { bound, literal }; } parseAttribute(isTemplateElement, attribute, matchableAttributes, parsedProperties, boundEvents, variables, references) { const name = normalizeAttributeName(attribute.name); const value = attribute.value; const srcSpan = attribute.sourceSpan; const absoluteOffset = attribute.valueSpan ? attribute.valueSpan.start.offset : srcSpan.start.offset; function createKeySpan(srcSpan, prefix, identifier) { // We need to adjust the start location for the keySpan to account for the removed 'data-' // prefix from `normalizeAttributeName`. const normalizationAdjustment = attribute.name.length - name.length; const keySpanStart = srcSpan.start.moveBy(prefix.length + normalizationAdjustment); const keySpanEnd = keySpanStart.moveBy(identifier.length); return new ParseSourceSpan(keySpanStart, keySpanEnd, keySpanStart, identifier); } const bindParts = name.match(BIND_NAME_REGEXP); if (bindParts) { if (bindParts[KW_BIND_IDX] != null) { const identifier = bindParts[IDENT_KW_IDX]; const keySpan = createKeySpan(srcSpan, bindParts[KW_BIND_IDX], identifier); this.bindingParser.parsePropertyBinding(identifier, value, false, srcSpan, absoluteOffset, attribute.valueSpan, matchableAttributes, parsedProperties, keySpan); } else if (bindParts[KW_LET_IDX]) { if (isTemplateElement) { const identifier = bindParts[IDENT_KW_IDX]; const keySpan = createKeySpan(srcSpan, bindParts[KW_LET_IDX], identifier); this.parseVariable(identifier, value, srcSpan, keySpan, attribute.valueSpan, variables); } else { this.reportError(`"let-" is only supported on ng-template elements.`, srcSpan); } } else if (bindParts[KW_REF_IDX]) { const identifier = bindParts[IDENT_KW_IDX]; const keySpan = createKeySpan(srcSpan, bindParts[KW_REF_IDX], identifier); this.parseReference(identifier, value, srcSpan, keySpan, attribute.valueSpan, references); } else if (bindParts[KW_ON_IDX]) { const events = []; const identifier = bindParts[IDENT_KW_IDX]; const keySpan = createKeySpan(srcSpan, bindParts[KW_ON_IDX], identifier); this.bindingParser.parseEvent(identifier, value, /* isAssignmentEvent */ false, srcSpan, attribute.valueSpan || srcSpan, matchableAttributes, events, keySpan); addEvents(events, boundEvents); } else if (bindParts[KW_BINDON_IDX]) { const identifier = bindParts[IDENT_KW_IDX]; const keySpan = createKeySpan(srcSpan, bindParts[KW_BINDON_IDX], identifier); this.bindingParser.parsePropertyBinding(identifier, value, false, srcSpan, absoluteOffset, attribute.valueSpan, matchableAttributes, parsedProperties, keySpan); this.parseAssignmentEvent(identifier, value, srcSpan, attribute.valueSpan, matchableAttributes, boundEvents, keySpan); } else if (bindParts[KW_AT_IDX]) { const keySpan = createKeySpan(srcSpan, '', name); this.bindingParser.parseLiteralAttr(name, value, srcSpan, absoluteOffset, attribute.valueSpan, matchableAttributes, parsedProperties, keySpan); } return true; } // We didn't see a kw-prefixed property binding, but we have not yet checked // for the []/()/[()] syntax. let delims = null; if (name.startsWith(BINDING_DELIMS.BANANA_BOX.start)) { delims = BINDING_DELIMS.BANANA_BOX; } else if (name.startsWith(BINDING_DELIMS.PROPERTY.start)) { delims = BINDING_DELIMS.PROPERTY; } else if (name.startsWith(BINDING_DELIMS.EVENT.start)) { delims = BINDING_DELIMS.EVENT; } if (delims !== null && // NOTE: older versions of the parser would match a start/end delimited // binding iff the property name was terminated by the ending delimiter // and the identifier in the binding was non-empty. // TODO(ayazhafiz): update this to handle malformed bindings. name.endsWith(delims.end) && name.length > delims.start.length + delims.end.length) { const identifier = name.substring(delims.start.length, name.length - delims.end.length); const keySpan = createKeySpan(srcSpan, delims.start, identifier); if (delims.start === BINDING_DELIMS.BANANA_BOX.start) { this.bindingParser.parsePropertyBinding(identifier, value, false, srcSpan, absoluteOffset, attribute.valueSpan, matchableAttributes, parsedProperties, keySpan); this.parseAssignmentEvent(identifier, value, srcSpan, attribute.valueSpan, matchableAttributes, boundEvents, keySpan); } else if (delims.start === BINDING_DELIMS.PROPERTY.start) { this.bindingParser.parsePropertyBinding(identifier, value, false, srcSpan, absoluteOffset, attribute.valueSpan, matchableAttributes, parsedProperties, keySpan); } else { const events = []; this.bindingParser.parseEvent(identifier, value, /* isAssignmentEvent */ false, srcSpan, attribute.valueSpan || srcSpan, matchableAttributes, events, keySpan); addEvents(events, boundEvents); } return true; } // No explicit binding found. const keySpan = createKeySpan(srcSpan, '' /* prefix */, name); const hasBinding = this.bindingParser.parsePropertyInterpolation(name, value, srcSpan, attribute.valueSpan, matchableAttributes, parsedProperties, keySpan, attribute.valueTokens ?? null); return hasBinding; } _visitTextWithInterpolation(value, sourceSpan, interpolatedTokens, i18n) { const valueNoNgsp = replaceNgsp(value); const expr = this.bindingParser.parseInterpolation(valueNoNgsp, sourceSpan, interpolatedTokens); return expr ? new t.BoundText(expr, sourceSpan, i18n) : new t.Text(valueNoNgsp, sourceSpan); } parseVariable(identifier, value, sourceSpan, keySpan, valueSpan, variables) { if (identifier.indexOf('-') > -1) { this.reportError(`"-" is not allowed in variable names`, sourceSpan); } else if (identifier.length === 0) { this.reportError(`Variable does not have a name`, sourceSpan); } variables.push(new t.Variable(identifier, value, sourceSpan, keySpan, valueSpan)); } parseReference(identifier, value, sourceSpan, keySpan, valueSpan, references) { if (identifier.indexOf('-') > -1) { this.reportError(`"-" is not allowed in reference names`, sourceSpan); } else if (identifier.length === 0) { this.reportError(`Reference does not have a name`, sourceSpan); } else if (references.some(reference => reference.name === identifier)) { this.reportError(`Reference "#${identifier}" is defined more than once`, sourceSpan); } references.push(new t.Reference(identifier, value, sourceSpan, keySpan, valueSpan)); } parseAssignmentEvent(name, expression, sourceSpan, valueSpan, targetMatchableAttrs, boundEvents, keySpan) { const events = []; this.bindingParser.parseEvent(`${name}Change`, `${expression} =$event`, /* isAssignmentEvent */ true, sourceSpan, valueSpan || sourceSpan, targetMatchableAttrs, events, keySpan); addEvents(events, boundEvents); } reportError(message, sourceSpan, level = ParseErrorLevel.ERROR) { this.errors.push(new ParseError(sourceSpan, message, level)); } } class NonBindableVisitor { visitElement(ast) { const preparsedElement = preparseElement(ast); if (preparsedElement.type === PreparsedElementType.SCRIPT || preparsedElement.type === PreparsedElementType.STYLE || preparsedElement.type === PreparsedElementType.STYLESHEET) { // Skipping