All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.shindig.gadgets.templates.DefaultTemplateProcessor Maven / Gradle / Ivy

Go to download

Renders gadgets, provides the gadget metadata service, and serves all javascript required by the OpenSocial specification.

There is a newer version: 3.0.0-beta4
Show newest version
/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.shindig.gadgets.templates;

import org.apache.shindig.expressions.Expressions;
import org.apache.shindig.gadgets.GadgetELResolver;
import org.apache.shindig.gadgets.parse.HtmlSerialization;
import org.apache.shindig.gadgets.templates.tags.RepeatTagHandler;
import org.apache.shindig.gadgets.templates.tags.TagHandler;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.DocumentFragment;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.el.ELContext;
import javax.el.ELException;
import javax.el.ELResolver;
import javax.el.ValueExpression;

import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.inject.Inject;

/**
 * Implements a DOM-based OS templates compiler.
 * Supports:
 *   - ${...} style expressions in content and attributes
 *   - @if attribute
 *   - @repeat attribute
 * TODO:
 *   - Handle built-in/custom tags
 */
public class DefaultTemplateProcessor implements TemplateProcessor {
  
  private static final Logger logger = Logger.getLogger(DefaultTemplateProcessor.class.getName()); 
  
  public static final String PROPERTY_INDEX = "Index";
  public static final String PROPERTY_COUNT = "Count";
  
  public static final String ATTRIBUTE_IF = "if";
  public static final String ATTRIBUTE_INDEX = "index";
  public static final String ATTRIBUTE_REPEAT = "repeat";
  public static final String ATTRIBUTE_VAR = "var";
  public static final String ATTRIBUTE_CUR = "cur";
  
  /** 
   * Set of attributes in HTML 4 that are boolean, and may only be set
   * to that value, and should be omitted to indicate "false". 
   */
  private static final Set HTML4_BOOLEAN_ATTRIBUTES =
    ImmutableSet.of("checked", "compact", "declare", "defer", "disabled", "ismap",
        "multiple", "nohref", "noresize", "noshade", "nowrap", "readonly", "selected");
  
  private static final Set ONCREATE_ATTRIBUTES =
    ImmutableSet.of("oncreate", "x-oncreate");
  
  private final Expressions expressions;
  // Reused buffer for creating template output
  private final StringBuilder outputBuffer;

  private TagRegistry registry;
  private TemplateContext templateContext;
  private ELContext elContext;
  
  private int uniqueIdCounter = 0;
  
  @Inject
  public DefaultTemplateProcessor(Expressions expressions) {  
    this.expressions = expressions;
    outputBuffer = new StringBuilder();
  }

  /**
   * Process an entire template.
   * 
   * @param template the DOM template, typically a script element
   * @param templateContext a template context providing top-level
   *     variables
   * @param globals ELResolver providing global variables other
   *     than those in the templateContext
   * @return a document fragment with the resolved content
   */
  public DocumentFragment processTemplate(Element template,
      TemplateContext templateContext, ELResolver globals, TagRegistry registry) {

    this.registry = registry;
    this.templateContext = templateContext;
    this.elContext = expressions.newELContext(globals,
        new GadgetELResolver(templateContext.getGadget().getContext()),
        new TemplateELResolver(templateContext),
        new ElementELResolver());

    DocumentFragment result = template.getOwnerDocument().createDocumentFragment();
    processChildNodes(result, template);
    return result;
  }
  
  /** Process the children of an element or document. */
  public void processChildNodes(Node result, Node source) {
    NodeList nodes = source.getChildNodes();
    for (int i = 0; i < nodes.getLength(); i++) {
      processNode(result, nodes.item(i));
    }
  }
  
  public TemplateContext getTemplateContext() {
    return templateContext;
  }
  
  /**
   * Process a node.
   * 
   * @param result the target node where results should be inserted
   * @param source the source node of the template being processed
   */
  private void processNode(Node result, Node source) {
    switch (source.getNodeType()) {
      case Node.TEXT_NODE:
        processText(result, source.getTextContent());
        break;
      case Node.ELEMENT_NODE:
        processElement(result, (Element) source);
        break;
      case Node.DOCUMENT_NODE:
        processChildNodes(result, source);
        break;
    }
  }

  /**
   * Process text content by including non-expression content verbatim and
   * escaping expression content.

   * @param result the target node where results should be inserted
   * @param textContent the text content being processed
   */
  private void processText(Node result, String textContent) {
    Document ownerDocument = result.getOwnerDocument();
    
    int start = 0;
    int current = 0;
    while (current < textContent.length()) {
      current = textContent.indexOf("${", current);
      // No expressions, we're done
      if (current < 0) {
        break;
      }
      
      // An escaped expression "\${"
      if (current > 0 && textContent.charAt(current - 1) == '\\') {
        // Drop the \ by outputting everything before it, and moving past
        // the ${
        if (current - 1 > start) {
          String staticText = textContent.substring(start, current - 1);
          result.appendChild(ownerDocument.createTextNode(staticText));
        }
        
        start = current;
        current = current + 2;
        continue;
      }
      
      // Not a real expression, we're done
      int expressionEnd = textContent.indexOf('}', current + 2);
      if (expressionEnd < 0) {
        break;
      }
  
      // Append the existing static text, if any
      if (current > start) {
        result.appendChild(ownerDocument.createTextNode(textContent.substring(start, current)));
      }
      
      // Isolate the expression, parse and evaluate
      String expression = textContent.substring(current, expressionEnd + 1);
      String value = evaluate(expression, String.class, "");
      
      if (!"".equals(value)) {
        // And now escape
        outputBuffer.setLength(0);
        try {
          HtmlSerialization.printEscapedText(value, outputBuffer);
        } catch (IOException e) {
          // Can't happen writing to StringBuilder
          throw new RuntimeException(e);
        }
        
        result.appendChild(ownerDocument.createTextNode(outputBuffer.toString()));
      }
      
      // And continue with the next expression 
      current = start = expressionEnd + 1;
    }
    
    // Add any static text left over
    if (start < textContent.length()) {
      result.appendChild(ownerDocument.createTextNode(textContent.substring(start)));
    }
  }

  /**
   * Process repeater state, if needed, on an element.
   */
  private void processElement(final Node result, final Element element) {
    Attr repeat = element.getAttributeNode(ATTRIBUTE_REPEAT);
    if (repeat != null) {
      Iterable dataList = evaluate(repeat.getValue(), Iterable.class, null);
      processRepeat(result, element, dataList, new Runnable() {
        public void run() {
          processElementInner(result, element);
        }
      });
    } else {
      processElementInner(result, element);
    }
  }

  /**
   * @param result
   * @param element
   * @param dataList
   */
  public void processRepeat(Node result, Element element, Iterable dataList,
      Runnable onEachLoop) {
    if (dataList == null) {
      return;
    }
    
    // Compute list size
    int size = Iterables.size(dataList);
    
    if (size > 0) {
      // Save the initial EL state
      Map oldContext = templateContext.getContext();
      Object oldCur = templateContext.getCur();
      ValueExpression oldVarExpression = null;
      
      // Set the new Context variable.  Copy the old context to preserve
      // any existing "index" variable
      Map loopData = Maps.newHashMap(oldContext);
      loopData.put(PROPERTY_COUNT, size);
      templateContext.setContext(loopData);

      // TODO: This means that any loop with @var doesn't make the loop
      // variable available in the default expression context.
      // Update the specification to make this explicit.
      Attr varAttr = element.getAttributeNode(ATTRIBUTE_VAR);
      if (varAttr == null) {
        oldCur = templateContext.getCur();
      } else {
        oldVarExpression = elContext.getVariableMapper().resolveVariable(varAttr.getValue());
      }

      Attr indexVarAttr = element.getAttributeNode(ATTRIBUTE_INDEX);
      String indexVar = indexVarAttr == null ? PROPERTY_INDEX : indexVarAttr.getValue();
        
      int index = 0;
      for (Object data : dataList) {
        loopData.put(indexVar, index++);
        
        // Set up context for rendering inner node
        templateContext.setCur(data);
        if (varAttr != null) {
          ValueExpression varExpression = expressions.constant(data, Object.class);
          elContext.getVariableMapper().setVariable(varAttr.getValue(), varExpression);
        }
        
        onEachLoop.run();

      }
      
      // Restore EL state        
      if (varAttr == null) {
        templateContext.setCur(oldCur);
      } else {
        elContext.getVariableMapper().setVariable(varAttr.getValue(), oldVarExpression);
      }
      
      templateContext.setContext(oldContext);
    }
  }
  
  /**
   * Process conditionals and non-repeat attributes on an element 
   */
  private void processElementInner(Node result, Element element) {
    TagHandler handler = registry.getHandlerFor(element);
    
    // An ugly special-case:   will re-evaluate the "if" attribute
    // (as it should) for each loop of the repeat.  Don't evaluate it here.
    if (!(handler instanceof RepeatTagHandler)) {
      Attr ifAttribute = element.getAttributeNode(ATTRIBUTE_IF);
      if (ifAttribute != null) {
        if (!evaluate(ifAttribute.getValue(), Boolean.class, false)) {
          return;
        }
      }
    }

    // TODO: the spec is silent on order of evaluation of "cur" relative
    // to "if" and "repeat"
    Attr curAttribute = element.getAttributeNode(ATTRIBUTE_CUR);
    Object oldCur = templateContext.getCur();
    if (curAttribute != null) {
      templateContext.setCur(evaluate(curAttribute.getValue(), Object.class, null));
    }
    
    if (handler != null) {
      handler.process(result, element, this);
    } else {
      // Be careful cloning nodes! If a target node belongs to a different document than the
      // template node then use importNode rather than cloneNode as that avoids side-effects
      // in UserDataHandlers where the cloned template node would belong to its original
      // document before being adopted by the target document.
      Element resultNode;
      if (element.getOwnerDocument() != result.getOwnerDocument()) {
        resultNode = (Element)result.getOwnerDocument().importNode(element, false);
      } else {
        resultNode = (Element)element.cloneNode(false);
      }
      
      clearSpecialAttributes(resultNode);
      Node additionalNode = processAttributes(resultNode);
      
      processChildNodes(resultNode, element);
      result.appendChild(resultNode);      
      
      if (additionalNode != null) {
        result.appendChild(additionalNode);
      }
    }
    
    if (curAttribute != null) {
      templateContext.setCur(oldCur);
    }
  }

  private void clearSpecialAttributes(Element element) {
    element.removeAttribute(ATTRIBUTE_IF);
    element.removeAttribute(ATTRIBUTE_REPEAT);
    element.removeAttribute(ATTRIBUTE_INDEX);
    element.removeAttribute(ATTRIBUTE_VAR);
    element.removeAttribute(ATTRIBUTE_CUR);
  }
  
  /**
   * Process expressions on attributes.
   * @param element The Element to process attributes on
   * @return Node to attach after this Element, or null
   */
  private Node processAttributes(Element element) {
    NamedNodeMap attributes = element.getAttributes();
    Node additionalNode = null;
    
    // Mutations to perform after iterating (if needed)
    List attrsToRemove = null;
    String newId = null;
    
    for (int i = 0; i < attributes.getLength(); i++) {
      boolean removeThisAttribute = false;
      
      Attr attribute = (Attr) attributes.item(i);
      // Boolean attributes: evaluate as a boolean.  If true, set the value to the
      // name of the attribute, e.g. selected="selected".  If false, remove the attribute
      // altogether.  The check here has some limitations for efficiency:  it assumes the
      // attribute is lowercase, and doesn't bother to check whether the boolean attribute
      // actually exists on the referred element (but HTML has no attrs that are sometimes
      // boolean and sometimes not)
      if (element.getNamespaceURI() == null &&
          HTML4_BOOLEAN_ATTRIBUTES.contains(attribute.getName())) {
        if (Boolean.TRUE.equals(evaluate(attribute.getValue(), Boolean.class, Boolean.FALSE))) {
          attribute.setNodeValue(attribute.getName());
        } else {
          removeThisAttribute = true;
        }
      } else if (ONCREATE_ATTRIBUTES.contains(attribute.getName())) {
        String id = element.getAttribute("id");
        if (id.length() == 0) {
          newId = id = getUniqueId();
        }
        
        additionalNode = buildOnCreateScript(
            evaluate(attribute.getValue(), String.class, null), id, element.getOwnerDocument());
        removeThisAttribute = true;
      } else {      
        attribute.setNodeValue(evaluate(attribute.getValue(), String.class, null));
      }
      
      // Because NamedNodeMaps are live, removing them interferes with iteration.
      // Remove the attributes in a later pass
      if (removeThisAttribute) {
        if (attrsToRemove == null) {
          attrsToRemove = Lists.newArrayListWithCapacity(attributes.getLength());
        }
        
        attrsToRemove.add(attribute);
      }
    }
    
    // Now that iteration is complete, perform mutations
    if (attrsToRemove != null) {
      for (Attr attr : attrsToRemove) {
        element.removeAttributeNode(attr);
      }
    }
    
    if (newId != null) {
      element.setAttribute("id", newId);
    }
    
    return additionalNode;
  }
  
  /**
   * Inserts an inline script element that executes a snippet of Javascript 
   * code after the element is emitted.
   * 

* The approach used involves using Javascript to find the previous sibling * node and apply the code to it - this avoids decorating nodes with IDs, an * approach that could potentially clash with existing element IDs that could * be non-unique. *

* The resulting script element is subject to sanitization. *

* @param code Javascript code to execute * @param id Element ID which should be used * @param document document for creating elements * * TODO: Move boilerplate code for finding the right node out to a function * to reduce code size. */ private Node buildOnCreateScript(String code, String id, Document document) { Element script = document.createElement("script"); script.setAttribute("type", "text/javascript"); StringBuilder builder = new StringBuilder(); builder.append("(function(){"); builder.append(code); builder.append("}).apply(document.getElementById('"); builder.append(id); builder.append("'));"); script.setTextContent(builder.toString()); return script; } /** * Evaluates an expression within the scope of this processor's context. * @param expression The String expression * @param type Expected result type * @param defaultValue Default value to return in case of error */ public T evaluate(String expression, Class type, T defaultValue) { try { ValueExpression expr = expressions.parse(expression, type); Object result = expr.getValue(elContext); return type.cast(result); } catch (ELException e) { logger.log(Level.WARNING, "EL failure for gadget {0}: {1}", new Object[]{getTemplateContext().getGadget().getContext().getUrl(), e.getMessage()}); return defaultValue; } } private String getUniqueId() { return "ostid" + (uniqueIdCounter++); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy