org.apache.shindig.gadgets.templates.DefaultTemplateProcessor Maven / Gradle / Ivy
Show all versions of shindig-gadgets Show documentation
/*
* 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++);
}
}