/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/compiler/src/render3/r3_template_transform.ts
1 296 строк
39 KB
Matthieu Riegler
refactor(compiler): remove `visitAttributeComment`
30 июн 2026, 02:00
30 июн 2026, 02:00
74803c7
Код
Авторство
О чём код?
/** * @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.dev/license */ import { BindingType, EmptyExpr, ParsedEvent, ParsedProperty, ParsedVariable, } from '../expression_parser/ast'; import * as i18n from '../i18n/i18n_ast'; import * as html from '../ml_parser/ast'; import {replaceNgsp} from '../ml_parser/html_whitespaces'; import {isNgTemplate} from '../ml_parser/tags'; import {InterpolatedAttributeToken, InterpolatedTextToken} from '../ml_parser/tokens'; import {ParseError, ParseErrorLevel, ParseSourceSpan} from '../parse_util'; import {isStyleUrlResolvable} from '../style_url_resolver'; import {isI18nRootNode} from '../template/pipeline/src/ingest'; import {BindingParser} from '../template_parser/binding_parser'; import {PreparsedElementType, preparseElement} from '../template_parser/template_preparser'; import * as t from './r3_ast'; import {createContentBlock} from './r3_content_blocks'; import { createForLoop, createIfBlock, createSwitchBlock, isConnectedForLoopBlock, isConnectedIfLoopBlock, } from './r3_control_flow'; import {createDeferredBlock, isConnectedDeferLoopBlock} from './r3_deferred_blocks'; import {I18N_ICU_VAR_PREFIX} 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 = '*'; // TODO(crisbeto): any other tag names that shouldn't be allowed here? const UNSUPPORTED_SELECTORLESS_TAGS = new Set([ 'link', 'style', 'script', 'ng-template', 'ng-container', 'ng-content', ]); // TODO(crisbeto): any other attributes that should not be allowed here? const UNSUPPORTED_SELECTORLESS_DIRECTIVE_ATTRS = new Set(['ngProjectAs', 'ngNonBindable']); // Result of the html AST to Ivy AST transformation export interface Render3ParseResult { nodes: t.Node[]; errors: ParseError[]; styles: string[]; styleUrls: string[]; ngContentSelectors: string[]; // Will be defined if `Render3ParseOptions['collectCommentNodes']` is true commentNodes?: t.Comment[]; } interface Render3ParseOptions { collectCommentNodes: boolean; } export function htmlAstToRender3Ast( htmlNodes: html.Node[], bindingParser: BindingParser, options: Render3ParseOptions, ): Render3ParseResult { const transformer = new HtmlAstToIvyAst(bindingParser, options); const ivyNodes = html.visitAll(transformer, htmlNodes, htmlNodes); // Errors might originate in either the binding parser or the html to ivy transformer const allErrors = bindingParser.errors.concat(transformer.errors); const result: Render3ParseResult = { nodes: ivyNodes, errors: allErrors, styleUrls: transformer.styleUrls, styles: transformer.styles, ngContentSelectors: transformer.ngContentSelectors, }; if (options.collectCommentNodes) { result.commentNodes = transformer.commentNodes; } return result; } class HtmlAstToIvyAst implements html.Visitor { errors: ParseError[] = []; styles: string[] = []; styleUrls: string[] = []; ngContentSelectors: string[] = []; // This array will be populated if `Render3ParseOptions['collectCommentNodes']` is true commentNodes: t.Comment[] = []; private inI18nBlock: boolean = false; /** * Keeps track of the nodes that have been processed already when previous nodes were visited. * These are typically blocks connected to other blocks or text nodes between connected blocks. */ private processedNodes = new Set<html.Block | html.Text>(); constructor( private bindingParser: BindingParser, private options: Render3ParseOptions, ) {} // HTML visitor visitElement(element: html.Element): t.Node | null { 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 `<ng-template>` const isTemplateElement = isNgTemplate(element.name); const { attributes, boundEvents, references, variables, templateVariables, elementHasInlineTemplate, parsedProperties, templateParsedProperties, i18nAttrsMeta, } = this.prepareAttributes(element.attrs, isTemplateElement); const directives = this.extractDirectives(element); let children: t.Node[]; if (preparsedElement.nonBindable) { // The `NonBindableVisitor` may need to return an array of nodes for blocks 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, element.children); } let parsedElement: t.Content | t.Template | t.Element | undefined; if (preparsedElement.type === PreparsedElementType.NG_CONTENT) { const selector = preparsedElement.selectAttr; const attrs: t.TextAttribute[] = element.attrs.map((attr) => this.visitAttribute(attr)); parsedElement = new t.Content( selector, attrs, children, element.isSelfClosing, element.sourceSpan, element.startSourceSpan, element.endSourceSpan, element.i18n, ); this.ngContentSelectors.push(selector); } else if (isTemplateElement) { // `<ng-template>` const attrs = this.categorizePropertyAttributes( element.name, parsedProperties, i18nAttrsMeta, ); parsedElement = new t.Template( element.name, attributes, attrs.bound, boundEvents, directives, [ /* no template attributes */ ], children, references, variables, element.isSelfClosing, element.sourceSpan, element.startSourceSpan, element.endSourceSpan, element.i18n, ); } else { const attrs = this.categorizePropertyAttributes( element.name, parsedProperties, i18nAttrsMeta, ); if (element.name === 'ng-container') { for (const bound of attrs.bound) { if (bound.type === BindingType.Attribute) { this.reportError( `Attribute bindings are not supported on ng-container. Use property bindings instead.`, bound.sourceSpan, ); } } } parsedElement = new t.Element( element.name, attributes, attrs.bound, boundEvents, directives, children, references, element.isSelfClosing, element.sourceSpan, element.startSourceSpan, element.endSourceSpan, element.isVoid, element.i18n, ); } if (this.options.collectCommentNodes) { element.comments.forEach((comment) => { this.commentNodes.push(new t.Comment(comment.value || '', comment.sourceSpan)); }); } 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. parsedElement = this.wrapInTemplate( parsedElement, templateParsedProperties, templateVariables, i18nAttrsMeta, isTemplateElement, isI18nRootElement, ); } if (isI18nRootElement) { this.inI18nBlock = false; } return parsedElement; } visitAttribute(attribute: html.Attribute): t.TextAttribute { return new t.TextAttribute( attribute.name, attribute.value, attribute.sourceSpan, attribute.keySpan, attribute.valueSpan, attribute.i18n, ); } visitText(text: html.Text): t.Node | null { return this.processedNodes.has(text) ? null : this._visitTextWithInterpolation(text.value, text.sourceSpan, text.tokens, text.i18n); } visitExpansion(expansion: html.Expansion): t.Icu | null { 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: {[name: string]: t.BoundText} = {}; const placeholders: {[name: string]: t.Text | t.BoundText} = {}; // 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: html.ExpansionCase): null { return null; } visitComment(comment: html.Comment): null { if (this.options.collectCommentNodes) { this.commentNodes.push(new t.Comment(comment.value || '', comment.sourceSpan)); } return null; } visitStartTagComment(comment: html.StartTagComment): null { if (this.options.collectCommentNodes) { this.commentNodes.push(new t.Comment(comment.value || '', comment.sourceSpan)); } return null; } visitLetDeclaration(decl: html.LetDeclaration, context: any) { const value = this.bindingParser.parseBinding( decl.value, false, decl.valueSpan, decl.valueSpan.start.offset, ); if (value.errors.length === 0 && value.ast instanceof EmptyExpr) { this.reportError('@let declaration value cannot be empty', decl.valueSpan); } return new t.LetDeclaration(decl.name, value, decl.sourceSpan, decl.nameSpan, decl.valueSpan); } visitComponent(component: html.Component) { const isI18nRootElement = isI18nRootNode(component.i18n); if (isI18nRootElement) { if (this.inI18nBlock) { this.reportError( 'Cannot mark a component as translatable inside of a translatable section. Please remove the nested i18n marker.', component.sourceSpan, ); } this.inI18nBlock = true; } if (component.tagName !== null && UNSUPPORTED_SELECTORLESS_TAGS.has(component.tagName)) { this.reportError( `Tag name "${component.tagName}" cannot be used as a component tag`, component.startSourceSpan, ); return null; } const { attributes, boundEvents, references, templateVariables, elementHasInlineTemplate, parsedProperties, templateParsedProperties, i18nAttrsMeta, } = this.prepareAttributes(component.attrs, false); this.validateSelectorlessReferences(references); const directives = this.extractDirectives(component); let children: t.Node[]; if (component.attrs.find((attr) => attr.name === 'ngNonBindable')) { // The `NonBindableVisitor` may need to return an array of nodes for blocks 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, component.children).flat(Infinity); } else { children = html.visitAll(this, component.children, component.children); } const attrs = this.categorizePropertyAttributes( component.tagName, parsedProperties, i18nAttrsMeta, ); let node: t.Component | t.Template = new t.Component( component.componentName, component.tagName, component.fullName, attributes, attrs.bound, boundEvents, directives, children, references, component.isSelfClosing, component.sourceSpan, component.startSourceSpan, component.endSourceSpan, component.i18n, ); if (this.options.collectCommentNodes) { component.comments.forEach((comment) => { this.commentNodes.push(new t.Comment(comment.value || '', comment.sourceSpan)); }); } if (elementHasInlineTemplate) { node = this.wrapInTemplate( node, templateParsedProperties, templateVariables, i18nAttrsMeta, false, isI18nRootElement, ); } if (isI18nRootElement) { this.inI18nBlock = false; } return node; } visitDirective() { return null; } visitBlockParameter() { return null; } visitBlock(block: html.Block, context: html.Node[]) { const index = Array.isArray(context) ? context.indexOf(block) : -1; if (index === -1) { throw new Error( 'Visitor invoked incorrectly. Expecting visitBlock to be invoked siblings array as its context', ); } // Connected blocks may have been processed as a part of the previous block. if (this.processedNodes.has(block)) { return null; } let result: {node: t.Node | null; errors: ParseError[]} | null = null; switch (block.name) { case 'defer': result = createDeferredBlock( block, this.findConnectedBlocks(index, context, isConnectedDeferLoopBlock), this, this.bindingParser, ); break; case 'switch': result = createSwitchBlock(block, this, this.bindingParser); break; case 'content': result = createContentBlock(block, this); break; case 'for': result = createForLoop( block, this.findConnectedBlocks(index, context, isConnectedForLoopBlock), this, this.bindingParser, ); break; case 'if': result = createIfBlock( block, this.findConnectedBlocks(index, context, isConnectedIfLoopBlock), this, this.bindingParser, ); break; default: let errorMessage: string; if (isConnectedDeferLoopBlock(block.name)) { errorMessage = `@${block.name} block can only be used after an @defer block.`; this.processedNodes.add(block); } else if (isConnectedForLoopBlock(block.name)) { errorMessage = `@${block.name} block can only be used after an @for block.`; this.processedNodes.add(block); } else if (isConnectedIfLoopBlock(block.name)) { errorMessage = `@${block.name} block can only be used after an @if or @else if block.`; this.processedNodes.add(block); } else { errorMessage = `Unrecognized block @${block.name}.`; } result = { node: new t.UnknownBlock(block.name, block.sourceSpan, block.nameSpan), errors: [new ParseError(block.sourceSpan, errorMessage)], }; break; } this.errors.push(...result.errors); return result.node; } private findConnectedBlocks( primaryBlockIndex: number, siblings: html.Node[], predicate: (blockName: string) => boolean, ): html.Block[] { const relatedBlocks: html.Block[] = []; for (let i = primaryBlockIndex + 1; i < siblings.length; i++) { const node = siblings[i]; // Skip over comments. if (node instanceof html.Comment) { continue; } // Ignore empty text nodes between blocks. if (node instanceof html.Text && node.value.trim().length === 0) { // Add the text node to the processed nodes since we don't want // it to be generated between the connected nodes. this.processedNodes.add(node); continue; } // Stop searching as soon as we hit a non-block node or a block that is unrelated. if (!(node instanceof html.Block) || !predicate(node.name)) { break; } relatedBlocks.push(node); this.processedNodes.add(node); } return relatedBlocks; } /** Splits up the property attributes depending on whether they're static or bound. */ private categorizePropertyAttributes( elementName: string | null, properties: ParsedProperty[], i18nPropsMeta: {[key: string]: i18n.I18nMeta}, ): {bound: t.BoundAttribute[]; literal: t.TextAttribute[]} { const bound: t.BoundAttribute[] = []; const literal: t.TextAttribute[] = []; 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 isAttrOn = prop.name.toLowerCase().startsWith('attr.on'); const bep = this.bindingParser.createBoundElementProperty( elementName, prop, /* skipValidation */ !isAttrOn, /* mapPropertyName */ false, ); bound.push(t.BoundAttribute.fromBoundElementProperty(bep, i18n)); } }); return {bound, literal}; } private prepareAttributes(attrs: html.Attribute[], isTemplateElement: boolean) { const parsedProperties: ParsedProperty[] = []; const boundEvents: t.BoundEvent[] = []; const variables: t.Variable[] = []; const references: t.Reference[] = []; const attributes: t.TextAttribute[] = []; const i18nAttrsMeta: Record<string, i18n.I18nMeta> = {}; const templateParsedProperties: ParsedProperty[] = []; const templateVariables: t.Variable[] = []; // Whether the element has any *-attribute let elementHasInlineTemplate = false; for (const attribute of attrs) { let hasBinding = false; // `*attr` defines template bindings let isTemplateBinding = false; if (attribute.i18n) { i18nAttrsMeta[attribute.name] = attribute.i18n; } if (attribute.name.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 = attribute.name.substring(TEMPLATE_ATTR_PREFIX.length); const parsedVariables: ParsedVariable[] = []; const absoluteValueOffset = attribute.valueSpan ? attribute.valueSpan.fullStart.offset : // If there is no value span the attribute does not have a value, like `attr` in //`<div attr></div>`. In this case, point to one character beyond the last character of // the attribute name. attribute.sourceSpan.fullStart.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)); } } return { attributes, boundEvents, references, variables, templateVariables, elementHasInlineTemplate, parsedProperties, templateParsedProperties, i18nAttrsMeta, }; } private parseAttribute( isTemplateElement: boolean, attribute: html.Attribute, matchableAttributes: string[][], parsedProperties: ParsedProperty[], boundEvents: t.BoundEvent[], variables: t.Variable[], references: t.Reference[], ) { const name = attribute.name; const value = attribute.value; const srcSpan = attribute.sourceSpan; const absoluteOffset = attribute.valueSpan ? attribute.valueSpan.fullStart.offset : srcSpan.fullStart.offset; function createKeySpan(srcSpan: ParseSourceSpan, prefix: string, identifier: string) { // We need to adjust the start location for the keySpan to account for the removed 'data-' // prefix from `normalizeAttributeName`. const keySpanStart = srcSpan.start.moveBy(prefix.length); 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) { // 3p-only-start const identifier = bindParts[IDENT_KW_IDX]; const keySpan = createKeySpan(srcSpan, bindParts[KW_BIND_IDX], identifier); this.bindingParser.parsePropertyBinding( identifier, value, false, false, srcSpan, absoluteOffset, attribute.valueSpan, matchableAttributes, parsedProperties, keySpan, ); // 3p-only-end } 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]) { // 3p-only-start const events: ParsedEvent[] = []; 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); // 3p-only-end } else if (bindParts[KW_BINDON_IDX]) { // 3p-only-start const identifier = bindParts[IDENT_KW_IDX]; const keySpan = createKeySpan(srcSpan, bindParts[KW_BINDON_IDX], identifier); this.bindingParser.parsePropertyBinding( identifier, value, false, true, srcSpan, absoluteOffset, attribute.valueSpan, matchableAttributes, parsedProperties, keySpan, ); this.parseAssignmentEvent( identifier, value, srcSpan, attribute.valueSpan, matchableAttributes, boundEvents, keySpan, absoluteOffset, ); // 3p-only-end } 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: {start: string; end: string} | null = 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, true, srcSpan, absoluteOffset, attribute.valueSpan, matchableAttributes, parsedProperties, keySpan, ); this.parseAssignmentEvent( identifier, value, srcSpan, attribute.valueSpan, matchableAttributes, boundEvents, keySpan, absoluteOffset, ); } else if (delims.start === BINDING_DELIMS.PROPERTY.start) { this.bindingParser.parsePropertyBinding( identifier, value, false, false, srcSpan, absoluteOffset, attribute.valueSpan, matchableAttributes, parsedProperties, keySpan, ); } else { const events: ParsedEvent[] = []; 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; } private extractDirectives(node: html.Element | html.Component): t.Directive[] { const elementName = node instanceof html.Component ? node.tagName : node.name; const directives: t.Directive[] = []; const seenDirectives = new Set<string>(); for (const directive of node.directives) { let invalid = false; for (const attr of directive.attrs) { if (attr.name.startsWith(TEMPLATE_ATTR_PREFIX)) { invalid = true; this.reportError( `Shorthand template syntax "${attr.name}" is not supported inside a directive context`, attr.sourceSpan, ); } else if (UNSUPPORTED_SELECTORLESS_DIRECTIVE_ATTRS.has(attr.name)) { invalid = true; this.reportError( `Attribute "${attr.name}" is not supported in a directive context`, attr.sourceSpan, ); } } if (!invalid && seenDirectives.has(directive.name)) { invalid = true; this.reportError( `Cannot apply directive "${directive.name}" multiple times on the same element`, directive.sourceSpan, ); } if (invalid) { continue; } const {attributes, parsedProperties, boundEvents, references, i18nAttrsMeta} = this.prepareAttributes(directive.attrs, false); this.validateSelectorlessReferences(references); const {bound: inputs} = this.categorizePropertyAttributes( elementName, parsedProperties, i18nAttrsMeta, ); for (const input of inputs) { if (input.type !== BindingType.Property && input.type !== BindingType.TwoWay) { invalid = true; this.reportError('Binding is not supported in a directive context', input.sourceSpan); } } if (invalid) { continue; } seenDirectives.add(directive.name); directives.push( new t.Directive( directive.name, attributes, inputs, boundEvents, references, directive.sourceSpan, directive.startSourceSpan, directive.endSourceSpan, undefined, ), ); } return directives; } private filterAnimationAttributes(attributes: t.TextAttribute[]): t.TextAttribute[] { return attributes.filter((a) => !a.name.startsWith('animate.')); } private filterAnimationInputs(attributes: t.BoundAttribute[]): t.BoundAttribute[] { return attributes.filter((a) => a.type !== BindingType.Animation); } private wrapInTemplate( node: t.Element | t.Component | t.Content | t.Template, templateProperties: ParsedProperty[], templateVariables: t.Variable[], i18nAttrsMeta: Record<string, i18n.I18nMeta>, isTemplateElement: boolean, isI18nRootElement: boolean, ) { // We need to hoist the attributes of the node to the template for content projection purposes. const attrs = this.categorizePropertyAttributes( 'ng-template', templateProperties, i18nAttrsMeta, ); const templateAttrs: (t.TextAttribute | t.BoundAttribute)[] = []; attrs.literal.forEach((attr) => templateAttrs.push(attr)); attrs.bound.forEach((attr) => templateAttrs.push(attr)); const hoistedAttrs = { attributes: [] as t.TextAttribute[], inputs: [] as t.BoundAttribute[], outputs: [] as t.BoundEvent[], }; if (node instanceof t.Element || node instanceof t.Component) { hoistedAttrs.attributes.push(...this.filterAnimationAttributes(node.attributes)); hoistedAttrs.inputs.push(...this.filterAnimationInputs(node.inputs)); hoistedAttrs.outputs.push(...node.outputs); } // For <ng-template>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 : node.i18n; let name: string | null; if (node instanceof t.Component) { name = node.tagName; } else if (node instanceof t.Template) { name = null; } else { name = node.name; } return new t.Template( name, hoistedAttrs.attributes, hoistedAttrs.inputs, hoistedAttrs.outputs, [ // Do not copy over the directives. ], templateAttrs, [node], [ // Do not copy over the references. ], templateVariables, false, node.sourceSpan, node.startSourceSpan, node.endSourceSpan, i18n, ); } private _visitTextWithInterpolation( value: string, sourceSpan: ParseSourceSpan, interpolatedTokens: InterpolatedAttributeToken[] | InterpolatedTextToken[] | null, i18n?: i18n.I18nMeta, ): t.Text | t.BoundText { 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); } private parseVariable( identifier: string, value: string, sourceSpan: ParseSourceSpan, keySpan: ParseSourceSpan, valueSpan: ParseSourceSpan | undefined, variables: t.Variable[], ) { 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)); } private parseReference( identifier: string, value: string, sourceSpan: ParseSourceSpan, keySpan: ParseSourceSpan, valueSpan: ParseSourceSpan | undefined, references: t.Reference[], ) { 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)); } private parseAssignmentEvent( name: string, expression: string, sourceSpan: ParseSourceSpan, valueSpan: ParseSourceSpan | undefined, targetMatchableAttrs: string[][], boundEvents: t.BoundEvent[], keySpan: ParseSourceSpan, absoluteOffset: number, ) { const events: ParsedEvent[] = []; this.bindingParser.parseEvent( `${name}Change`, expression, /* isAssignmentEvent */ true, sourceSpan, valueSpan || sourceSpan, targetMatchableAttrs, events, keySpan, ); addEvents(events, boundEvents); } private validateSelectorlessReferences(references: t.Reference[]): void { if (references.length === 0) { return; } const seenNames = new Set<string>(); for (const ref of references) { if (ref.value.length > 0) { this.reportError( 'Cannot specify a value for a local reference in this context', ref.valueSpan || ref.sourceSpan, ); } else if (seenNames.has(ref.name)) { this.reportError('Duplicate reference names are not allowed', ref.sourceSpan); } else { seenNames.add(ref.name); } } } private reportError( message: string, sourceSpan: ParseSourceSpan, level: ParseErrorLevel = ParseErrorLevel.ERROR, ) { this.errors.push(new ParseError(sourceSpan, message, level)); } } class NonBindableVisitor implements html.Visitor { visitElement(ast: html.Element): t.Element | null { const preparsedElement = preparseElement(ast); if ( preparsedElement.type === PreparsedElementType.SCRIPT || preparsedElement.type === PreparsedElementType.STYLE || preparsedElement.type === PreparsedElementType.STYLESHEET ) { // Skipping <script> for security reasons // Skipping <style> and stylesheets as we already processed them // in the StyleCompiler return null; } const children: t.Node[] = html.visitAll(this, ast.children, null); return new t.Element( ast.name, html.visitAll(this, ast.attrs) as t.TextAttribute[], /* inputs */ [], /* outputs */ [], /* directives */ [], children, /* references */ [], ast.isSelfClosing, ast.sourceSpan, ast.startSourceSpan, ast.endSourceSpan, ast.isVoid, ); } visitComment(comment: html.Comment): any { return null; } visitAttribute(attribute: html.Attribute): t.TextAttribute { return new t.TextAttribute( attribute.name, attribute.value, attribute.sourceSpan, attribute.keySpan, attribute.valueSpan, attribute.i18n, ); } visitText(text: html.Text): t.Text { return new t.Text(text.value, text.sourceSpan); } visitExpansion(expansion: html.Expansion): any { return null; } visitExpansionCase(expansionCase: html.ExpansionCase): any { return null; } visitBlock(block: html.Block, context: any) { const nodes = [ // In an ngNonBindable context we treat the opening/closing tags of block as plain text. // This is the as if the `tokenizeBlocks` option was disabled. new t.Text(block.startSourceSpan.toString(), block.startSourceSpan), ...html.visitAll(this, block.children), ]; if (block.endSourceSpan !== null) { nodes.push(new t.Text(block.endSourceSpan.toString(), block.endSourceSpan)); } return nodes; } visitBlockParameter(parameter: html.BlockParameter, context: any) { return null; } visitLetDeclaration(decl: html.LetDeclaration, context: any) { return new t.Text(`@let ${decl.name} = ${decl.value};`, decl.sourceSpan); } visitComponent(ast: html.Component, context: any) { const children: t.Node[] = html.visitAll(this, ast.children, null); return new t.Element( ast.fullName, html.visitAll(this, ast.attrs) as t.TextAttribute[], /* inputs */ [], /* outputs */ [], /* directives */ [], children, /* references */ [], ast.isSelfClosing, ast.sourceSpan, ast.startSourceSpan, ast.endSourceSpan, false, ); } visitDirective(directive: html.Directive, context: any) { return null; } } const NON_BINDABLE_VISITOR = new NonBindableVisitor(); function addEvents(events: ParsedEvent[], boundEvents: t.BoundEvent[]) { boundEvents.push(...events.map((e) => t.BoundEvent.fromParsedEvent(e))); } function textContents(node: html.Element): string | null { if (node.children.length !== 1 || !(node.children[0] instanceof html.Text)) { return null; } else { return (node.children[0] as html.Text).value; } }