/* * Copyright 2015 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.template.soy.html.passes; import static com.google.common.base.CharMatcher.whitespace; import com.google.common.base.Preconditions; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ListMultimap; import com.google.template.soy.base.SourceLocation; import com.google.template.soy.base.internal.IdGenerator; import com.google.template.soy.data.SanitizedContent.ContentKind; import com.google.template.soy.error.ErrorReporter; import com.google.template.soy.error.SoyErrorKind; import com.google.template.soy.html.HtmlDefinitions; import com.google.template.soy.html.IncrementalHtmlAttributeNode; import com.google.template.soy.html.IncrementalHtmlCloseTagNode; import com.google.template.soy.html.IncrementalHtmlOpenTagNode; import com.google.template.soy.html.InferredElementNamespace; import com.google.template.soy.soytree.AbstractSoyNodeVisitor; import com.google.template.soy.soytree.AutoescapeMode; import com.google.template.soy.soytree.CallNode; import com.google.template.soy.soytree.CallParamContentNode; import com.google.template.soy.soytree.CssNode; import com.google.template.soy.soytree.HtmlContext; import com.google.template.soy.soytree.IfCondNode; import com.google.template.soy.soytree.IfElseNode; import com.google.template.soy.soytree.LetContentNode; import com.google.template.soy.soytree.LogNode; import com.google.template.soy.soytree.MsgFallbackGroupNode; import com.google.template.soy.soytree.NamespaceDeclaration; import com.google.template.soy.soytree.PrintNode; import com.google.template.soy.soytree.RawTextNode; import com.google.template.soy.soytree.SoyFileNode; import com.google.template.soy.soytree.SoyFileSetNode; import com.google.template.soy.soytree.SoyNode; import com.google.template.soy.soytree.SoyNode.LoopNode; import com.google.template.soy.soytree.SoyNode.ParentSoyNode; import com.google.template.soy.soytree.SoyNode.RenderUnitNode; import com.google.template.soy.soytree.SoyNode.StandaloneNode; import com.google.template.soy.soytree.SoyTreeUtils; import com.google.template.soy.soytree.SwitchCaseNode; import com.google.template.soy.soytree.SwitchDefaultNode; import com.google.template.soy.soytree.TemplateNode; import com.google.template.soy.soytree.XidNode; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; import java.util.HashSet; import java.util.List; import java.util.Set; /** * Translates fragments of HTML tags, text nodes and attributes found in {@link RawTextNode}s to the * following nodes: * * <ul> * <li>{@link IncrementalHtmlAttributeNode} * <li>{@link IncrementalHtmlCloseTagNode} * <li>{@link IncrementalHtmlOpenTagNode} * </ul> * * Also annotates msg and print nodes with their {@link HtmlContext}. * * <p>{@link RawTextNode}s not found in a place where HTML or attributes may be present, such as in * a {@link XidNode}, are left alone. */ public final class HtmlTransformVisitor extends AbstractSoyNodeVisitor<Void> { private static final SoyErrorKind ENDING_STATE_MISMATCH = SoyErrorKind.of( "Ending context of the content within a Soy tag must match the starting context. " + "Transition was from {0} to {1}"); private static final SoyErrorKind EXPECTED_ATTRIBUTE_VALUE = SoyErrorKind.of("Expected to find a quoted " + "attribute value, but found \"{0}\"."); private static final SoyErrorKind EXPECTED_TAG_CLOSE = SoyErrorKind.of("Expected to find the tag close character, >, but found \"{0}\"."); private static final SoyErrorKind INVALID_SELF_CLOSING_TAG = SoyErrorKind.of( "Invalid self-closing tag for \"{0}\". Self-closing tags are only valid for void tags and" + " SVG content (partially supported). For a list of void elements, see " + "https://www.w3.org/TR/html5/syntax.html#void-elements."); private static final SoyErrorKind SOY_TAG_BEFORE_ATTR_VALUE = SoyErrorKind.of( "Soy statements are not " + "allowed before an attribute value. They should be moved inside a quotation mark."); private static final SoyErrorKind MISSING_TAG_NAME = SoyErrorKind.of("Found a tag with an empty tag " + "name."); private static final SoyErrorKind NON_STRICT_FILE = SoyErrorKind.of("The incremental HTML Soy backend " + "requires strict autoescape mode"); private static final SoyErrorKind NON_STRICT_TEMPLATE = SoyErrorKind.of( "The incremental HTML Soy " + "backend requires strict autoescape mode for all templates."); private static final SoyErrorKind UNKNOWN_CONTENT_KIND = SoyErrorKind.of( "The incremental HTML Soy backend requires all let statements and parameters with " + "content to have a content kind"); private static final SoyErrorKind INVALID_CSS_NODE_LOCATION = SoyErrorKind.of( "The incremental HTML Soy backend does not allow '{'css'}' nodes to appear in HTML " + "outside of attribute values."); private static final SoyErrorKind INVALID_XID_NODE_LOCATION = SoyErrorKind.of( "The incremental HTML Soy backend does not allow '{'xid'}' nodes to appear in HTML " + "outside of attribute values."); /** The last {@link HtmlContext} encountered. */ private HtmlContext currentState = HtmlContext.HTML_PCDATA; /** True if we're expecting '>' after '/'. Will only be true in TAG. */ private boolean isSelfClosingTag; /** The name of the current tag. */ private String currentTag = ""; /** * The current 'token' being built up. This may correspond to a tag name, attribute name, * attribute value or text node. */ private final StringBuilder currentText = new StringBuilder(); /** The name of the current attribute being examined. */ private String currentAttributeName = ""; /** The {@link StandaloneNode}s that make up the value of the current attribute. */ private List<StandaloneNode> currentAttributeValues = new ArrayList<>(); /** * The node that should be the parent of the nodes representing attributes or control structures * containing attributes in the portion of the tree currently being visited. */ private ParentSoyNode<StandaloneNode> currentAttributesParent; /** Used to give newly created Nodes an id. */ private IdGenerator idGen = null; /** Keeps track of the current open elements within a template */ private final Deque<IncrementalHtmlOpenTagNode> openElementsDeque = new ArrayDeque<>(); /** * Maps a RawTextNode to nodes corresponding to one or more HTML tag pieces or attributes. This is * added to by {@link #visitRawTextNode(RawTextNode)} whenever the end of a piece is encountered. * After {@link #exec(SoyNode)} finishes, the RawTextNodes are replaced with the corresponding * nodes. */ private final ListMultimap<RawTextNode, StandaloneNode> transformMapping = ArrayListMultimap.create(); /** The {@link RawTextNode}s that have been visited and should be removed. */ private final Set<RawTextNode> visitedRawTextNodes = new HashSet<>(); /** * Used to prevent reporting an error on each token after an equals if a non-quoted attribute * value is used, allowing the visitor to visit the rest of the tree looking for issues without a * flood of errors being generated. */ private boolean suppressExpectedAttributeValueError = false; private final ErrorReporter errorReporter; public HtmlTransformVisitor(ErrorReporter errorReporter) { this.errorReporter = errorReporter; } /** * Transforms all the {@link RawTextNode}s corresponding to HTML to the corresponding Html*Node. * Additionally, nodes that occur in HTML data or attributes declarations are annotated with * {@link HtmlContext}. * * @see AbstractSoyNodeVisitor#exec(com.google.template.soy.basetree.Node) */ @Override public Void exec(SoyNode node) { super.exec(node); applyTransforms(); return null; } /** * Applies the built up transforms, changing {@link RawTextNode}s to the corresponding Html*Nodes, * if any. If a node itself does not correspond to any new nodes, it is simply removed. */ private void applyTransforms() { for (RawTextNode node : visitedRawTextNodes) { ParentSoyNode<StandaloneNode> parent = node.getParent(); parent.addChildren(parent.getChildIndex(node), transformMapping.get(node)); parent.removeChild(node); } } private HtmlContext getState() { return currentState; } private void setState(HtmlContext state) { currentState = state; isSelfClosingTag = false; } private void setSelfClosingTagState() { currentState = HtmlContext.HTML_TAG; isSelfClosingTag = true; } /** * Derives a {@link SourceLocation} from the given {@link RawTextNode}, for text the length of * charSequence ending at endOffset. */ private SourceLocation deriveSourceLocation(RawTextNode node) { // TODO(sparhami) Since the parser strips templates and combines whitespace, including newlines // we can't find the correct source location based on where we are in the RawTextNode. The // parser needs to be modified to not strip whitespace and to not combine text before and after // comments. Doing the former is difficult due to commands like {\n}. return node.getSourceLocation(); } /** * Creates a new {@link RawTextNode} in HTML context and maps it to node. * * @param node The node that the mapped node comes from. */ private void createTextNode(RawTextNode node) { Preconditions.checkState(getState() == HtmlContext.HTML_PCDATA); String currentString = consumeText(); if (currentString.length() > 0) { SourceLocation sl = deriveSourceLocation(node); transformMapping.put(node, new RawTextNode(idGen.genId(), currentString, sl, getState())); } } /** * Creates a {@link RawTextNode} for the current part of an attribute value and adds it to the * pending attribute value array. * * @param node The node that the mapped node comes from. */ private void createAttributeValueNode(RawTextNode node) { Preconditions.checkState(getState() == HtmlContext.HTML_NORMAL_ATTR_VALUE); String currentString = consumeText(); // Check to see if the currentText is empty. This may occur when we have something like // disabled="" or disabled="{$foo}" after the print tag is finished. if (currentString.length() > 0) { SourceLocation sl = deriveSourceLocation(node); currentAttributeValues.add(new RawTextNode(idGen.genId(), currentString, sl, getState())); } } /** * Creates a new {@link IncrementalHtmlAttributeNode} and maps it to node, taking all the * attribute values (text, conditionals, print statements) and adding them to the new attribute * node. * * @param node The node that the mapped node comes from. */ private void createAttribute(RawTextNode node) { SourceLocation sl = deriveSourceLocation(node); IncrementalHtmlAttributeNode htmlAttributeNode = new IncrementalHtmlAttributeNode(idGen.genId(), currentAttributeName, sl); htmlAttributeNode.addChildren(currentAttributeValues); if (currentAttributesParent != null && !SoyTreeUtils.isDescendantOf(node, currentAttributesParent)) { currentAttributesParent.addChild(htmlAttributeNode); } else { transformMapping.put(node, htmlAttributeNode); } currentAttributeValues = new ArrayList<>(); } /** * Handles a character within {@link HtmlContext#PCDATA}, where either a text node may be present * or the start of a new tag. * * @param node The node that the current character belongs to. * @param c The current character being examined. */ private void handleHtmlPcData(RawTextNode node, char c) { if (c == '<') { // If we are encountering the start of a new tag, check to see if a text node with data is // being completed. createTextNode(node); setState(HtmlContext.HTML_TAG_NAME); } else { currentText.append(c); } } private String consumeText() { String token = currentText.toString(); currentText.setLength(0); return token; } private void startCapturingAttributes() { currentAttributeValues = new ArrayList<>(); setState(HtmlContext.HTML_TAG); } /** * Handles a character within {@link HtmlContext#TAG_NAME}, where the name of a tag must be * present. * * @param node The node that the current character belongs to. * @param c The current character being examined. */ private void handleHtmlTagName(RawTextNode node, char c) { if (whitespace().matches(c) || c == '>' || (currentText.length() != 0 && c == '/')) { currentTag = consumeText(); // No tag name, saw something like <> or < >. if (currentTag.length() <= 0) { SourceLocation sl = deriveSourceLocation(node); errorReporter.report(sl, MISSING_TAG_NAME); } // Currently, closing tags and open tags are handled through the states. If this is not a // closing tag, then an open tag needs to be started. if (!currentTag.startsWith("/")) { SourceLocation sl = deriveSourceLocation(node); currentAttributesParent = new IncrementalHtmlOpenTagNode(idGen.genId(), currentTag, getNamespace(currentTag), sl); } if (c == '>' || c == '/') { // Handle close tags and tags that only have a tag name (e.g. <div>). handleHtmlTag(node, c); } else { startCapturingAttributes(); } } else { currentText.append(c); } } /** * @param tagName The tag name to get the namespace for, given the current stack of open Elements. */ private InferredElementNamespace getNamespace(String tagName) { if (tagName.equalsIgnoreCase("svg")) { return InferredElementNamespace.SVG; } // If at the root of a template, treat it as being in the XHTML namespace. Ideally, we would // be able to check the union of the callsites and figure out what namespace were from there. // Ultimately we cannot tell if a template will be rendered into an SVG at runtime. For almost // all cases, XHTML is the right value however. if (tagName.equalsIgnoreCase("foreignObject") || openElementsDeque.isEmpty()) { return InferredElementNamespace.XHTML; } return openElementsDeque.peek().getNamespace(); } private void emitHtmlOpenTagNode( String tagName, boolean isSelfClosing, RawTextNode node, SourceLocation sl) { InferredElementNamespace namespace = getNamespace(tagName); IncrementalHtmlOpenTagNode htmlOpenTagNode = (IncrementalHtmlOpenTagNode) currentAttributesParent; transformMapping.put(node, htmlOpenTagNode); openElementsDeque.push(htmlOpenTagNode); currentAttributesParent = null; if (!isSelfClosing) { return; } /* * Per spec, only two types of tags allow self-closing: void tags (e.g. input) and * svg/math content. We ignore math content since only Firefox supports it. Having a self * closing tag on all others is a parse error. For more info, look for "self-closing flag" in * the spec. * TODO(sparhami) we currently track the open elements stack on a per-template basis. That means * if you have the svg element itself declared in one template, and svg content with a self * closing tag in another, we will currently trip up on it. If all the callsites for a * particular template are all within svg content, we could inherit that initial namespace. */ if (namespace == InferredElementNamespace.SVG) { emitHtmlCloseTagNode(tagName, node, sl); } else if (!HtmlDefinitions.HTML5_VOID_ELEMENTS.contains(currentTag)) { // Continuing parsing while treating the tag as a simple start tag matches how browsers // handle this case. Note: a close tag is not emitted. errorReporter.report(sl, INVALID_SELF_CLOSING_TAG, currentTag); } } private void emitHtmlCloseTagNode(String tagName, RawTextNode node, SourceLocation sl) { transformMapping.put(node, new IncrementalHtmlCloseTagNode(idGen.genId(), tagName, sl)); boolean tagMatches = false; // When encountering a closing tag, need to pop off any unclosed tags. while (!openElementsDeque.isEmpty() && !tagMatches) { IncrementalHtmlOpenTagNode htmlOpenTagNode = openElementsDeque.pop(); tagMatches = tagName.equalsIgnoreCase(htmlOpenTagNode.getTagName()); } } /** * Handles a character within {@link HtmlContext#TAG}, where either an attribute declaration or * the end of a tag may appear. * * @param node The node that the current character belongs to. * @param c The current character being examined. */ private void handleHtmlTag(RawTextNode node, char c) { if (c == '>') { // Found the end of the tag - create the appropriate open tag or close tag node, depending // on which we are ending. SourceLocation sl = deriveSourceLocation(node); if (currentTag.startsWith("/")) { emitHtmlCloseTagNode(currentTag.substring(1), node, sl); } else { emitHtmlOpenTagNode(currentTag, false, node, sl); } setState(HtmlContext.HTML_PCDATA); } else if (c == '/') { setSelfClosingTagState(); } else if (whitespace().matches(c)) { // Skip whitespace characters. } else { setState(HtmlContext.HTML_ATTRIBUTE_NAME); currentText.append(c); } } /** Handles 8.2.4.43 of the W3C HTML5 spec */ private void handleHtmlSelfClosingStartTag(RawTextNode node, char c) { SourceLocation sl = deriveSourceLocation(node); if (c != '>') { errorReporter.report(sl, EXPECTED_TAG_CLOSE, c); setState(HtmlContext.HTML_TAG); consumeCharacter(node, c); return; } emitHtmlOpenTagNode(currentTag, true, node, sl); setState(HtmlContext.HTML_PCDATA); } /** * Handles the state where an attribute name is being declared. If an =, > or whitespace character * is encountered, then the attribute name is completed. * * @param node The node that the current character belongs to * @param c The current character being examined */ private void handleHtmlAttributeName(RawTextNode node, char c) { if (c == '=') { // Next thing we should see is " to start the attribute value. currentAttributeName = consumeText(); setState(HtmlContext.HTML_BEFORE_ATTRIBUTE_VALUE); suppressExpectedAttributeValueError = false; } else if (c == '>') { // Tag ended with an attribute with no value (e.g. disabled) - create an attribute, then // handle the tag end. currentAttributeName = consumeText(); createAttribute(node); handleHtmlTag(node, c); } else if (whitespace().matches(c)) { // Handle a value-less attribute, then start looking for another attribute or the end of the // tag. currentAttributeName = consumeText(); createAttribute(node); setState(HtmlContext.HTML_TAG); } else { currentText.append(c); } } /** * Handle the next character after the equals in the attribute declaration. The only allowed * character is a double quote. * * @param node The node that the current character belongs to. * @param c The current character being examined. */ private void handleHtmlBeforeAttributeValue(RawTextNode node, char c) { if (c == '"') { setState(HtmlContext.HTML_NORMAL_ATTR_VALUE); } else if (!suppressExpectedAttributeValueError) { SourceLocation sl = deriveSourceLocation(node); errorReporter.report(sl, EXPECTED_ATTRIBUTE_VALUE, c); suppressExpectedAttributeValueError = true; } // Just move on if we see a space or closing bracket so that the rest of the tree can be checked // for issues. if (c == '>') { handleHtmlTag(node, c); } else if (whitespace().matches(c)) { setState(HtmlContext.HTML_TAG); } } /** * Handles an HTML attribute value. When an end quote is encountered, a new {@link * IncrementalHtmlAttributeNode} is created with the {@link SoyNode}s that make up the value. * * @param node The node that the current character belongs to. * @param c The current character being examined. */ private void handleHtmlNormalAttrValue(RawTextNode node, char c) { if (c == '"') { createAttributeValueNode(node); createAttribute(node); setState(HtmlContext.HTML_TAG); } else { currentText.append(c); } } /** * Consumes a single character, taking action to create a node if necessary or just adding it to * the current pending text. * * @param node The node that the current character belongs to. * @param c The current character being examined. */ private void consumeCharacter(RawTextNode node, char c) { switch (getState()) { case HTML_PCDATA: handleHtmlPcData(node, c); break; case HTML_TAG_NAME: handleHtmlTagName(node, c); break; case HTML_TAG: if (isSelfClosingTag) { handleHtmlSelfClosingStartTag(node, c); } else { handleHtmlTag(node, c); } break; case HTML_ATTRIBUTE_NAME: handleHtmlAttributeName(node, c); break; case HTML_BEFORE_ATTRIBUTE_VALUE: handleHtmlBeforeAttributeValue(node, c); break; case HTML_NORMAL_ATTR_VALUE: handleHtmlNormalAttrValue(node, c); break; default: break; } } /** * Visits a {@link RawTextNode}, going through each of the characters and building up the HTML * pieces (e.g. {@link IncrementalHtmlOpenTagNode} and {@link IncrementalHtmlCloseTagNode}). The * new pieces are mapped to the {@link RawTextNode} where they ended. The {@link * #applyTransforms()} method actually performs the replacement. */ @Override protected void visitRawTextNode(RawTextNode node) { String content = node.getRawText(); // Mark all visited RawTextNodes for removal. A single RawTextNode may not map to any Html*Nodes // by itself, but we still want to remove it. visitedRawTextNodes.add(node); for (int i = 0; i < content.length(); i += 1) { consumeCharacter(node, content.charAt(i)); } switch (getState()) { case HTML_TAG_NAME: /* * Force the end of a tag in the case we have something like: * <div{if $foo}...{/if} ...> */ consumeCharacter(node, ' '); break; case HTML_PCDATA: createTextNode(node); break; case HTML_ATTRIBUTE_NAME: // Value-less attribute inside a soy block, e.g. {if $condition}disabled{/if} consumeCharacter(node, ' '); break; case HTML_NORMAL_ATTR_VALUE: /* * Reached the end of a RawTextNode with some text, for example from: * * <div foo="bar {if $condition}...{/if}"> * * Take the text up until the end of the RawTextNode, "bar ", and add it to the attribute * values. */ createAttributeValueNode(node); break; default: break; } } /** * Checks to see if a given {@link SoyNode} is valid in the current context, reporting an error if * it is not. */ private void checkForValidSoyNodeLocation(SoyNode node) { switch (getState()) { case HTML_BEFORE_ATTRIBUTE_VALUE: errorReporter.report(node.getSourceLocation(), SOY_TAG_BEFORE_ATTR_VALUE); break; default: break; } } /** * Visits a {@link PrintNode}, annotating it with an {@link HtmlContext}. This allows the code * generator to handle HTML print statements separately and know the state in which they occurred. * If the {@link PrintNode} occurs in {@link HtmlContext#HTML_NORMAL_ATTR_VALUE}, the print node * becomes part of the current attribute's value. */ @Override protected void visitPrintNode(PrintNode node) { checkForValidSoyNodeLocation(node); if (getState() == HtmlContext.HTML_NORMAL_ATTR_VALUE) { // A PrintNode in an attribute value, add it to the current attribute values, which will get // added to the attribute node once the attribute value ends. currentAttributeValues.add(node); node.getParent().removeChild(node); } else if (getState() == HtmlContext.HTML_TAG) { moveToCurrentAttributesParent(node); } node.setHtmlContext(getState()); } /** * Visit {@link LetContentNode}s and {@link CallParamContentNode}s, transforming the {@link * RawTextNode}s inside to The corresponding Html* nodes. * * <ul> * <li>For {@link ContentKind#HTML}, it simply visits the children and does the normal * transformation. * <li>For {@link ContentKind#ATTRIBUTES}, it transforms the children as if they were in the * attribute declaration portion of an HTML tag. * <li>All other kinds {@link ContentKind}s are ignored by this visitor, leaving content within * things like kind="text" alone. * </ul> * * @param node A {@link LetContentNode}or {@link CallParamContentNode} */ private void visitLetParamContentNode(RenderUnitNode node) { checkForValidSoyNodeLocation(node); if (node.getContentKind() == null) { errorReporter.report(node.getSourceLocation(), UNKNOWN_CONTENT_KIND); } else if (node.getContentKind() == ContentKind.HTML) { visitSoyNode(node, true); } else if (node.getContentKind() == ContentKind.ATTRIBUTES) { HtmlContext startState = getState(); startCapturingAttributes(); currentAttributesParent = node; visitChildrenAllowingConcurrentModification(node); currentAttributesParent = null; setState(startState); } else { new ContextSetterVisitor(HtmlContext.TEXT).exec(node); } } @Override protected void visitLetContentNode(LetContentNode node) { visitLetParamContentNode(node); } @Override protected void visitCallParamContentNode(CallParamContentNode node) { visitLetParamContentNode(node); } /** Visits a {@link SoyFileNode}, making sure it has strict autoescape. */ @Override protected void visitSoyFileNode(SoyFileNode node) { NamespaceDeclaration namespaceDeclaration = node.getNamespaceDeclaration(); if (namespaceDeclaration.getDefaultAutoescapeMode() != AutoescapeMode.STRICT) { errorReporter.report(namespaceDeclaration.getAutoescapeModeLocation(), NON_STRICT_FILE); } visitChildren(node); } /** Visits a {@link SoyFileNode}, getting its id generator. */ @Override protected void visitSoyFileSetNode(SoyFileSetNode node) { idGen = node.getNodeIdGenerator(); visitChildren(node); } /** * Visits a {@link TemplateNode}, processing those that have kind html or attributes and making * sure that the autoescape mode is strict. */ @Override protected void visitTemplateNode(TemplateNode node) { switch (node.getContentKind()) { case HTML: currentState = HtmlContext.HTML_PCDATA; break; case ATTRIBUTES: currentState = HtmlContext.HTML_TAG; currentAttributesParent = node; break; default: new ContextSetterVisitor(HtmlContext.TEXT).exec(node); return; // only need to do transformations for HTML / attributes } if (node.getAutoescapeMode() != AutoescapeMode.STRICT) { errorReporter.report(node.getSourceLocation(), NON_STRICT_TEMPLATE); } openElementsDeque.clear(); visitSoyNode(node, true); currentAttributesParent = null; } /** Visits a {@link CallNode} - makes sure that the node does not occur in an invalid location. */ @Override protected void visitCallNode(CallNode node) { checkForValidSoyNodeLocation(node); visitSoyNode(node); } @Override protected void visitIfCondNode(IfCondNode node) { visitSoyNode(node, true); } @Override protected void visitIfElseNode(IfElseNode node) { visitSoyNode(node, true); } @Override protected void visitSwitchCaseNode(SwitchCaseNode node) { visitSoyNode(node, true); } @Override protected void visitSwitchDefaultNode(SwitchDefaultNode node) { visitSoyNode(node, true); } @Override protected void visitLoopNode(LoopNode node) { visitSoyNode(node, true); } @Override protected void visitCssNode(CssNode node) { if (getState() != HtmlContext.HTML_NORMAL_ATTR_VALUE) { errorReporter.report(node.getSourceLocation(), INVALID_CSS_NODE_LOCATION); } visitSoyNode(node); } @Override protected void visitXidNode(XidNode node) { if (getState() != HtmlContext.HTML_NORMAL_ATTR_VALUE) { errorReporter.report(node.getSourceLocation(), INVALID_XID_NODE_LOCATION); } visitSoyNode(node); } @Override protected void visitMsgFallbackGroupNode(MsgFallbackGroupNode node) { node.setHtmlContext(getState()); visitSoyNode(node); } @Override protected void visitLogNode(LogNode node) { // The contents of a {log} statement are always text. new ContextSetterVisitor(HtmlContext.TEXT).exec(node); } private void visitSoyNode(SoyNode node, boolean enforceState) { switch (getState()) { case HTML_BEFORE_ATTRIBUTE_VALUE: errorReporter.report(node.getSourceLocation(), SOY_TAG_BEFORE_ATTR_VALUE); break; case HTML_NORMAL_ATTR_VALUE: if (node instanceof StandaloneNode) { StandaloneNode standaloneNode = (StandaloneNode) node; standaloneNode.getParent().removeChild(standaloneNode); currentAttributeValues.add(standaloneNode); // We don't need to transform the children, but we do need to set their contexts. new ContextSetterVisitor(getState()) { // {param} tags inside attributes can only be TEXT. @Override protected void visitCallParamContentNode(CallParamContentNode node) { new ContextSetterVisitor(HtmlContext.TEXT).exec(node); } }.exec(node); } break; case HTML_TAG: moveToCurrentAttributesParent(node); visitChildrenAndCheckState(node, enforceState); break; case HTML_PCDATA: visitChildrenAndCheckState(node, enforceState); break; default: break; } } private void visitChildrenAndCheckState(SoyNode node, boolean enforceState) { if (node instanceof ParentSoyNode) { HtmlContext startState = getState(); visitChildrenAllowingConcurrentModification((ParentSoyNode<?>) node); HtmlContext endState = getState(); if (enforceState && startState != endState) { errorReporter.report(node.getSourceLocation(), ENDING_STATE_MISMATCH, startState, endState); } consumeText(); } } /** * Moves the given node under the current attributes parent node, if it's not in its subtree * already. */ private void moveToCurrentAttributesParent(SoyNode node) { if (currentAttributesParent != null && !SoyTreeUtils.isDescendantOf(node, currentAttributesParent) && node instanceof StandaloneNode) { StandaloneNode standaloneNode = (StandaloneNode) node; standaloneNode.getParent().removeChild(standaloneNode); currentAttributesParent.addChild(standaloneNode); } } // ----------------------------------------------------------------------------------------------- // Fallback implementation. @Override protected void visitSoyNode(SoyNode node) { visitSoyNode(node, false); } /** * Sets the HtmlContext for all supported nodes to a specified value. The main visitor class calls * this visitor instead visiting nodes in textual contexts, since they don't need anything else. */ private static class ContextSetterVisitor extends AbstractSoyNodeVisitor<Void> { private final HtmlContext value; ContextSetterVisitor(HtmlContext value) { this.value = value; } @Override protected void visitSoyNode(SoyNode node) { if (node instanceof ParentSoyNode<?>) { visitChildren((ParentSoyNode<?>) node); } } @Override protected void visitMsgFallbackGroupNode(MsgFallbackGroupNode node) { node.setHtmlContext(value); visitChildren(node); } @Override protected void visitPrintNode(PrintNode node) { node.setHtmlContext(value); } @Override protected void visitRawTextNode(RawTextNode node) { node.setHtmlContext(value); } } }