You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@shindig.apache.org by aw...@apache.org on 2009/02/14 00:24:54 UTC

svn commit: r744279 - in /incubator/shindig/trunk/java/gadgets/src: main/java/org/apache/shindig/gadgets/rewrite/ main/java/org/apache/shindig/gadgets/templates/ test/java/org/apache/shindig/gadgets/rewrite/ test/java/org/apache/shindig/gadgets/templates/

Author: awiner
Date: Fri Feb 13 23:24:53 2009
New Revision: 744279

URL: http://svn.apache.org/viewvc?rev=744279&view=rev
Log:
SHINDIG-729 (in part): Partial implementation of server-side OpenSocial templating.  Implemented by Lev Epshteyn, with some tweaks from Adam Winer.

This code is not yet enabled by default - to enable it, bind SocialMarkupHtmlParser as the GadgetHtmlParser, and include
the Pipeline and Template rewriters (in that order) in the list of rewriters.

Implemented functionality includes:
- ${} support in content and attributes
- @if and @repeat

TODOs include:
- Built-in tags (os:Html, os:Repeat, etc.)
- Custom tags
- An end-to-end test (once the code is enabled by default)


Added:
    incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/TemplateRewriter.java
    incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/
    incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateContext.java
    incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateELResolver.java
    incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateProcessor.java
    incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/rewrite/TemplateRewriterTest.java
    incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/templates/
    incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/templates/TemplateProcessorTest.java
Modified:
    incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/MutableContent.java
    incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/PipelineDataContentRewriter.java

Modified: incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/MutableContent.java
URL: http://svn.apache.org/viewvc/incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/MutableContent.java?rev=744279&r1=744278&r2=744279&view=diff
==============================================================================
--- incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/MutableContent.java (original)
+++ incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/MutableContent.java Fri Feb 13 23:24:53 2009
@@ -17,13 +17,18 @@
  */
 package org.apache.shindig.gadgets.rewrite;
 
+import com.google.common.collect.Maps;
+
 import org.apache.shindig.gadgets.GadgetException;
 import org.apache.shindig.gadgets.http.HttpResponse;
 import org.apache.shindig.gadgets.parse.GadgetHtmlParser;
 import org.apache.shindig.gadgets.parse.HtmlSerializer;
+import org.json.JSONObject;
 
 import org.w3c.dom.Document;
 
+import java.util.Map;
+
 /**
  * Object that maintains a String representation of arbitrary contents
  * and a consistent view of those contents as an HTML parse tree.
@@ -33,6 +38,7 @@
   private HttpResponse contentSource;
   private Document document;
   private final GadgetHtmlParser contentParser;
+  private final Map<String, JSONObject> pipelinedData;
 
   private static final String MUTABLE_CONTENT_LISTENER = "MutableContentListener";
 
@@ -49,6 +55,7 @@
   public MutableContent(GadgetHtmlParser contentParser, String content) {
     this.contentParser = contentParser;
     this.content = content;
+    pipelinedData = Maps.newHashMap();
   }
 
   /**
@@ -58,6 +65,7 @@
   public MutableContent(GadgetHtmlParser contentParser, HttpResponse contentSource) {
     this.contentParser = contentParser;
     this.contentSource = contentSource;
+    pipelinedData = Maps.newHashMap();
   }
 
 
@@ -141,4 +149,12 @@
   public boolean hasDocument() {
     return (document != null);
   }
+  
+  public void addPipelinedData(String key, JSONObject value) {
+    pipelinedData.put(key, value);
+  }
+  
+  public Map<String, JSONObject> getPipelinedData() {
+    return pipelinedData;
+  }
 }

Modified: incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/PipelineDataContentRewriter.java
URL: http://svn.apache.org/viewvc/incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/PipelineDataContentRewriter.java?rev=744279&r1=744278&r2=744279&view=diff
==============================================================================
--- incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/PipelineDataContentRewriter.java (original)
+++ incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/PipelineDataContentRewriter.java Fri Feb 13 23:24:53 2009
@@ -194,7 +194,7 @@
       String key = entry.getKey();
 
       // TODO: escape key
-      // TODO: push to MutableContent
+      content.addPipelinedData(key, entry.getValue());
       script.append("osd.DataContext.putDataSet(\"")
           .append(key)
           .append("\",")

Added: incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/TemplateRewriter.java
URL: http://svn.apache.org/viewvc/incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/TemplateRewriter.java?rev=744279&view=auto
==============================================================================
--- incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/TemplateRewriter.java (added)
+++ incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/rewrite/TemplateRewriter.java Fri Feb 13 23:24:53 2009
@@ -0,0 +1,146 @@
+/*
+ * 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.rewrite;
+
+import org.apache.shindig.common.xml.DomUtil;
+import org.apache.shindig.gadgets.Gadget;
+import org.apache.shindig.gadgets.GadgetELResolver;
+import org.apache.shindig.gadgets.http.HttpRequest;
+import org.apache.shindig.gadgets.http.HttpResponse;
+import org.apache.shindig.gadgets.spec.Feature;
+import org.apache.shindig.gadgets.templates.TemplateContext;
+import org.apache.shindig.gadgets.templates.TemplateProcessor;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringTokenizer;
+
+import org.json.JSONObject;
+import org.w3c.dom.DocumentFragment;
+import org.w3c.dom.Element;
+
+import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/**
+ * This ContentRewriter uses a TemplateProcessor to replace os-template
+ * tag contents of a gadget spec with their rendered equivalents.
+ * 
+ * Only templates without the @name and @tag attributes are processed 
+ * automatically.
+ */
+public class TemplateRewriter implements ContentRewriter {
+
+  public final static Set<String> TAGS = ImmutableSet.of("script");
+  
+  /**
+   * Provider of the processor.  TemplateRewriters are stateless and multithreaded,
+   * processors are not.
+   */
+  private final Provider<TemplateProcessor> processor;
+  
+  @Inject
+  public TemplateRewriter(Provider<TemplateProcessor> processor) {
+    this.processor = processor;
+  }
+  
+  public RewriterResults rewrite(HttpRequest request, HttpResponse original,
+      MutableContent content) {
+    return null;
+  }
+
+  public RewriterResults rewrite(Gadget gadget, MutableContent content) {
+    Feature f = gadget.getSpec().getModulePrefs().getFeatures()
+        .get("opensocial-templates");
+    if (f != null) {
+      return rewriteImpl(gadget, content);   
+    }
+    return null;
+  }
+  
+  private RewriterResults rewriteImpl(Gadget gadget, MutableContent content) {
+    List<Element> tagList =
+      DomUtil.getElementsByTagNameCaseInsensitive(content.getDocument(), TAGS);
+    final Map<String, JSONObject> pipelinedData = content.getPipelinedData();
+
+    List<Element> templates = ImmutableList.copyOf(
+        Iterables.filter(tagList, new Predicate<Element>() {
+      public boolean apply(Element element) {
+        String type = element.getAttribute("type");
+        String name = element.getAttribute("name");
+        String tag = element.getAttribute("tag");
+        String require = element.getAttribute("require");
+        // Templates with "tag" or "name" can't be processed;  templates
+        // that require data that isn't available on the server can't
+        // be processed either
+        return "text/os-template".equals(type)
+            && "".equals(name) 
+            && "".equals(tag)
+            && checkRequiredData(require, pipelinedData.keySet());
+      }
+    }));
+    
+    if (templates.isEmpty()) {
+      return null;
+    }
+    
+    TemplateContext templateContext = new TemplateContext(pipelinedData);
+    GadgetELResolver globalGadgetVars = new GadgetELResolver(gadget.getContext());
+    
+    for (Element template : templates) {
+      DocumentFragment result = processor.get().processTemplate(
+          template, templateContext, globalGadgetVars);
+      // Note: replaceNode errors when replacing Element with DocumentFragment
+      template.getParentNode().insertBefore(result, template);
+      // TODO: clients that need to update data that is initially available,
+      // e.g. paging through friend lists, will still need the template
+      template.getParentNode().removeChild(template);
+    }
+    
+    // TODO: Deactivate the "os-templates" feature if all templates have
+    // been rendered.
+    // Note: This may not always be correct behavior since client may want
+    // some purely client-side templating (such as from libraries)
+    MutableContent.notifyEdit(content.getDocument());
+    return RewriterResults.cacheableIndefinitely();
+  }
+  
+  /**
+   * Checks that all the required data is available at rewriting time.
+   * @param requiredData A string of comma-separated data set names
+   * @param availableData A map of available data sets
+   * @return true if all required data sets are present, false otherwise
+   */
+  private static boolean checkRequiredData(String requiredData, Set<String> availableData) {
+    if ("".equals(requiredData)) {
+      return true;
+    }
+    StringTokenizer st = new StringTokenizer(requiredData, ",");
+    while (st.hasMoreTokens()) {
+      if (!availableData.contains(st.nextToken().trim())) {
+        return false;
+      }
+    }
+    return true;
+  }
+}

Added: incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateContext.java
URL: http://svn.apache.org/viewvc/incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateContext.java?rev=744279&view=auto
==============================================================================
--- incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateContext.java (added)
+++ incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateContext.java Fri Feb 13 23:24:53 2009
@@ -0,0 +1,63 @@
+/*
+ * 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.json.JSONObject;
+
+import java.util.Map;
+
+import com.google.common.collect.ImmutableMap;
+
+/**
+ * Context for processing a single template.
+ */
+public class TemplateContext {
+  private final Map<String, JSONObject> top;
+  private Object cur = null;
+  // TODO: support unique Id
+  private Map<String, ? extends Object> context = ImmutableMap.of();
+
+  public TemplateContext(Map<String, JSONObject> top) {
+    this.top = top;
+  }
+  
+  public Map<String, ? extends Object> getTop() {
+    return top;
+  }
+  
+  public Object getCur() {
+    return cur != null ? cur : top;
+  }
+
+  public Object setCur(Object data) {
+    Object oldCur = cur;
+    cur = data;
+    return oldCur;
+  }
+
+  public Map<String, ? extends Object> getContext() {
+    return context;
+  }
+
+  public Map<String, ? extends Object> setContext(Map<String, ? extends Object> newContext) {
+    Map<String, ? extends Object> oldContext = context;
+    context = newContext;
+    return oldContext;
+  }
+}

Added: incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateELResolver.java
URL: http://svn.apache.org/viewvc/incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateELResolver.java?rev=744279&view=auto
==============================================================================
--- incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateELResolver.java (added)
+++ incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateELResolver.java Fri Feb 13 23:24:53 2009
@@ -0,0 +1,144 @@
+/*
+ * 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 java.beans.FeatureDescriptor;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+
+import javax.el.ELContext;
+import javax.el.ELResolver;
+import javax.el.PropertyNotWritableException;
+import javax.el.ValueExpression;
+
+import com.google.common.collect.ImmutableSet;
+
+/**
+ * ELResolver used to process OpenSocial templates.  Provides three variables:
+ * <ul>
+ * <li>"Top": Global values </li>
+ */
+public class TemplateELResolver extends ELResolver {
+  public static final String PROPERTY_TOP = "Top";
+  public static final String PROPERTY_CONTEXT = "Context";
+  public static final String PROPERTY_CUR = "Cur";
+  
+  private static final Set<String> TOP_LEVEL_PROPERTIES =
+    ImmutableSet.of(PROPERTY_TOP, PROPERTY_CONTEXT, PROPERTY_CUR);
+  
+  private final TemplateContext templateContext;
+
+  public TemplateELResolver(TemplateContext templateContext) {
+    this.templateContext = templateContext;  
+  }
+  
+  @Override
+  public Class<?> getCommonPropertyType(ELContext context, Object base) {
+    if (base == null) {
+      return String.class;
+    }
+    
+    return null;
+  }
+
+  @Override
+  public Iterator<FeatureDescriptor> getFeatureDescriptors(ELContext context,
+      Object base) {
+    return null;
+  }
+
+  @Override
+  public Class<?> getType(ELContext context, Object base, Object property) {
+    // TODO: implement
+    return null;
+  }
+
+  @Override
+  public Object getValue(ELContext context, Object base, Object property) {
+    if (base == null) {
+      if (TOP_LEVEL_PROPERTIES.contains(property)) {
+        context.setPropertyResolved(true);
+        if (PROPERTY_TOP.equals(property)) {
+          return templateContext.getTop();
+        } else if (PROPERTY_CONTEXT.equals(property)) {
+          return templateContext.getContext();
+        } else {
+          return templateContext.getCur();
+        }
+      }
+           
+      // Check variables.
+      if (property instanceof String) {
+        ValueExpression valueExp = context.getVariableMapper().resolveVariable((String) property);
+        if (valueExp != null) {
+          context.setPropertyResolved(true);
+          return valueExp.getValue(context);
+        }
+      }
+      
+      // Check current context next.
+      Object cur = templateContext.getCur();
+      // Resolve through "cur" as if it were a value - if "isPropertyResolved()"
+      // is true, it was handled
+      Object value = context.getELResolver().getValue(context, cur, property);
+      if (context.isPropertyResolved()) {
+        if (value != null) {
+          return value;
+        } else {
+          context.setPropertyResolved(false);
+        }
+      }
+      
+      // Check current scope variables next.
+      Map<String, ? extends Object> scope = templateContext.getContext();
+      if (scope.containsKey(property)) {
+        context.setPropertyResolved(true);
+        return scope.get(property);
+      }
+      
+      // Look at Top context last.
+      scope = templateContext.getTop();
+      if (scope.containsKey(property)) {
+        context.setPropertyResolved(true);
+        return scope.get(property);
+      }
+    }
+    
+    return null;
+  }
+
+  @Override
+  public boolean isReadOnly(ELContext context, Object base, Object property) {
+    if (base == null && TOP_LEVEL_PROPERTIES.contains(property)) {
+      context.setPropertyResolved(true);
+      return true;
+    }
+    
+    return false;
+  }
+
+  @Override
+  public void setValue(ELContext context, Object base, Object property, Object value) {
+    if (base == null && TOP_LEVEL_PROPERTIES.contains(property)) {
+      throw new PropertyNotWritableException();
+    }
+  }
+
+}

Added: incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateProcessor.java
URL: http://svn.apache.org/viewvc/incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateProcessor.java?rev=744279&view=auto
==============================================================================
--- incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateProcessor.java (added)
+++ incubator/shindig/trunk/java/gadgets/src/main/java/org/apache/shindig/gadgets/templates/TemplateProcessor.java Fri Feb 13 23:24:53 2009
@@ -0,0 +1,402 @@
+/*
+ * 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.parse.nekohtml.NekoSerializer;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.logging.Logger;
+
+import javax.el.ELContext;
+import javax.el.ELException;
+import javax.el.ELResolver;
+import javax.el.ValueExpression;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+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 com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+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 TemplateProcessor {
+  
+  private static final Logger logger = Logger.getLogger(TemplateProcessor.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";
+  
+  private final Expressions expressions;
+  // Reused buffer for creating template output
+  private final StringBuilder outputBuffer;
+
+  private TemplateContext templateContext;
+  private ELContext elContext;
+  
+  @Inject
+  public TemplateProcessor(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) {
+
+    this.templateContext = templateContext;
+    this.elContext = expressions.newELContext(globals,
+        new TemplateELResolver(templateContext));
+
+    DocumentFragment result = template.getOwnerDocument().createDocumentFragment();
+    processChildrenOf(result, template);
+    return result;
+  }
+  
+  /**
+   * 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:
+        processChildrenOf(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 = processString(expression);
+      
+      if (!"".equals(value)) {
+        // And now escape
+        outputBuffer.setLength(0);
+        try {
+          NekoSerializer.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(Node result, Element element) {
+    Attr repeat = element.getAttributeNode(ATTRIBUTE_REPEAT);
+    if (repeat != null) {
+      // TODO: Is Iterable the right interface here? The spec calls for
+      // length to be available.
+      Iterable<?> dataList = processIterable(repeat.getValue());
+      if (dataList != null) {
+        // Compute list size
+        int size = Iterables.size(dataList);
+        
+        // Save the initial EL state
+        Map<String, ? extends Object> 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<String, Object> 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);
+          }
+          
+          processElementInner(result, element);
+        }
+        
+        // Restore EL state        
+        if (varAttr == null) {
+          templateContext.setCur(oldCur);
+        } else {
+          elContext.getVariableMapper().setVariable(varAttr.getValue(), oldVarExpression);
+        }
+        
+        templateContext.setContext(oldContext);
+      }
+    } else {
+      processElementInner(result, element);
+    }
+  }
+  
+  /**
+   * Process conditionals and non-repeat attributes on an element 
+   */
+  private void processElementInner(Node result, Element element) {
+    Attr ifAttribute = element.getAttributeNode(ATTRIBUTE_IF);
+    if (ifAttribute != null) {
+      if (!processBoolean(ifAttribute.getValue())) {
+        return;
+      }
+    }
+
+    Element resultNode = (Element) element.cloneNode(false);
+    result.appendChild(resultNode);
+
+    // Remove special attributes
+    resultNode.removeAttribute(ATTRIBUTE_IF);
+    resultNode.removeAttribute(ATTRIBUTE_REPEAT);
+    resultNode.removeAttribute(ATTRIBUTE_INDEX);
+    resultNode.removeAttribute(ATTRIBUTE_VAR);
+
+    processAttributes(resultNode);
+    processChildrenOf(resultNode, element);
+  }
+
+  /**
+   * Process expressions on attributes
+   */
+  private void processAttributes(Element element) {
+    NamedNodeMap attributes = element.getAttributes();
+    for (int i = 0; i < attributes.getLength(); i++) {
+      Attr attribute = (Attr) attributes.item(i);
+      attribute.setNodeValue(processString(attribute.getValue()));
+    }
+  }
+  
+  /** Process the children of an element or document. */
+  private void processChildrenOf(Node result, Node parent) {
+    NodeList nodes = parent.getChildNodes();
+    for (int i = 0; i < nodes.getLength(); i++) {
+      processNode(result, nodes.item(i));
+    }
+  }
+
+  private String processString(String expression) {
+    String result = "";
+    try {
+      ValueExpression expr = expressions.parse(expression, String.class);
+      result = (String) expr.getValue(elContext);
+    } catch (ELException e) {
+      logger.warning(e.getMessage());
+    }
+    return result;    
+  }
+  
+  private boolean processBoolean(String expression) {
+    Boolean result = false;
+    try {
+      ValueExpression expr = expressions.parse(expression, Boolean.class);
+      result = (Boolean) expr.getValue(elContext);
+    } catch (ELException e) {
+      logger.warning(e.getMessage());
+    }
+    return result == null ? false : result;    
+  }
+  
+  private Iterable<?> processIterable(String expression) {
+    Iterable<?> result = null;
+    try {
+      ValueExpression expr = expressions.parse(expression, Object.class);
+      Object value = expr.getValue(elContext);
+      return coerceToIterable(value);
+    } catch (ELException e) {
+      logger.warning(e.getMessage());
+    }
+    return result;    
+  }
+  
+  /**
+   * Coerce objects to iterables.  Iterables and JSONArrays have the obvious
+   * coercion.  JSONObjects are coerced to single-element lists, unless
+   * they have a "list" property that is in array, in which case that's used.
+   */
+  private Iterable<?> coerceToIterable(Object value) {
+    if (value == null) {
+      return ImmutableList.of();
+    }
+    
+    if (value instanceof Iterable) {
+      return ((Iterable<?>) value);
+    }
+    
+    if (value instanceof JSONArray) {
+      final JSONArray array = (JSONArray) value;
+      // TODO: Extract JSONArrayIterator class?
+      return new Iterable<Object>() {
+        public Iterator<Object> iterator() {
+          return new Iterator<Object>() {
+            private int i = 0;
+            
+            public boolean hasNext() {
+              return i < array.length();
+            }
+          
+            public Object next() {
+              if (i >= array.length()) {
+                throw new NoSuchElementException();
+              }
+              
+              try {
+                return array.getJSONObject(i++);
+              } catch (Exception e) {
+                throw new ELException(e);
+              }
+            }
+          
+            public void remove() {
+              throw new UnsupportedOperationException();
+            }
+          };
+        }
+      };
+    }
+    
+    if (value instanceof JSONObject) {
+      JSONObject json = (JSONObject) value;
+      
+      // Does this object have a "list" property that is an array?
+      // TODO: add to specification
+      Object childList = json.opt("list");
+      if (childList != null && childList instanceof JSONArray) {
+        return coerceToIterable(childList);
+      }
+      
+      // A scalar JSON value is treated as a single element list.
+      return ImmutableList.of(json);
+    }
+    
+    return null;
+  }
+}
\ No newline at end of file

Added: incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/rewrite/TemplateRewriterTest.java
URL: http://svn.apache.org/viewvc/incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/rewrite/TemplateRewriterTest.java?rev=744279&view=auto
==============================================================================
--- incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/rewrite/TemplateRewriterTest.java (added)
+++ incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/rewrite/TemplateRewriterTest.java Fri Feb 13 23:24:53 2009
@@ -0,0 +1,160 @@
+/*
+ * 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.rewrite;
+
+import static org.junit.Assert.assertTrue;
+
+import org.apache.shindig.common.uri.Uri;
+import org.apache.shindig.expressions.Expressions;
+import org.apache.shindig.gadgets.Gadget;
+import org.apache.shindig.gadgets.GadgetContext;
+import org.apache.shindig.gadgets.parse.ParseModule;
+import org.apache.shindig.gadgets.parse.nekohtml.SocialMarkupHtmlParser;
+import org.apache.shindig.gadgets.spec.GadgetSpec;
+import org.apache.shindig.gadgets.spec.SpecParserException;
+import org.apache.shindig.gadgets.templates.TemplateProcessor;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.google.inject.Provider;
+
+/** 
+ * Tests for TemplateRewriter
+ */
+public class TemplateRewriterTest {
+  
+  private GadgetSpec gadgetSpec;
+  private Gadget gadget;
+  private MutableContent content;
+  private TemplateRewriter rewriter;
+  
+  private static final Uri GADGET_URI = Uri.parse("http://example.org/gadget.php");
+  
+  private static final String CONTENT_PLAIN =
+    "<script type='text/os-template'>Hello, ${user.name}</script>";  
+
+  private static final String CONTENT_REQUIRE =
+    "<script type='text/os-template' require='user'>Hello, ${user.name}</script>";  
+  
+  private static final String CONTENT_REQUIRE_MISSING =
+    "<script type='text/os-template' require='foo'>Hello, ${user.name}</script>";  
+
+  private static final String CONTENT_WITH_NAME =
+    "<script type='text/os-template' name='myTemplate'>Hello, ${user.name}</script>";  
+  
+  private static final String CONTENT_WITH_TAG =
+    "<script type='text/os-template' tag='foo:Bar'>Hello, ${user.name}</script>";  
+
+  
+  @Before
+  public void setUp() {
+    rewriter = new TemplateRewriter(
+        new Provider<TemplateProcessor>() {
+          public TemplateProcessor get() {
+            return new TemplateProcessor(Expressions.sharedInstance());
+          }
+        });
+  }
+  
+  @Test
+  public void simpleTemplate() throws Exception {
+    // Render a simple template
+    testExpectingTransform(getGadgetXml(CONTENT_PLAIN), "simple");
+  }
+  
+  @Test
+  public void noTemplateFeature() throws Exception {
+    // Without opensocial-templates feature, shouldn't render
+    testExpectingNoTransform(getGadgetXml(CONTENT_PLAIN, false), "no feature");
+  }
+  
+  @Test
+  public void requiredDataPresent() throws Exception {
+    // Required data is present - render 
+    testExpectingTransform(getGadgetXml(CONTENT_REQUIRE), "required data");
+  }
+  
+  @Test
+  public void requiredDataMissing() throws Exception {
+    // Required data is missing - don't render
+    testExpectingNoTransform(getGadgetXml(CONTENT_REQUIRE_MISSING), "missing data");
+  }
+  
+  @Test
+  public void nameAttributePresent() throws Exception {
+    // Don't render templates with a @name
+    testExpectingNoTransform(getGadgetXml(CONTENT_WITH_NAME), "with @name");
+  }
+  
+  @Test
+  public void tagAttributePresent() throws Exception {
+    // Don't render templates with a @tag
+    testExpectingNoTransform(getGadgetXml(CONTENT_WITH_TAG), "with @tag");
+  }
+   
+  private void testExpectingTransform(String code, String condition) throws Exception {
+    setupGadget(code);
+    rewriter.rewrite(gadget, content);
+    assertTrue("Template wasn't transformed (" + condition + ")", 
+        content.getContent().indexOf("Hello, John") > 0);
+    assertTrue("Template tag wasn't removed (" + condition + ")", 
+        content.getContent().indexOf("text/os-template") < 0);
+  }
+
+  private void testExpectingNoTransform(String code, String condition) throws Exception {
+    setupGadget(code);
+    rewriter.rewrite(gadget, content);
+    assertTrue("Template was transformed (" + condition + ")", 
+        content.getContent().indexOf("${user.name}") > 0);
+    assertTrue("Template tag was removed (" + condition + ")", 
+        content.getContent().indexOf("text/os-template") > 0);
+  }
+  
+  private void setupGadget(String gadgetXml) throws SpecParserException, JSONException {
+    gadgetSpec = new GadgetSpec(GADGET_URI, gadgetXml);
+    gadget = new Gadget();
+    gadget.setSpec(gadgetSpec);
+    gadget.setContext(new GadgetContext() {});
+    gadget.setCurrentView(gadgetSpec.getView("default"));
+
+    content = new MutableContent(new SocialMarkupHtmlParser(
+        new ParseModule.DOMImplementationProvider().get()), gadget.getCurrentView().getContent());
+    putPipelinedData("user", new JSONObject("{ name: 'John'}"));
+  }
+  
+  private void putPipelinedData(String key, JSONObject data) {
+    content.addPipelinedData(key, data);
+  }
+  
+  private static String getGadgetXml(String content) {
+    return getGadgetXml(content, true);
+  }
+  
+  private static String getGadgetXml(String content, boolean requireFeature) {
+    String feature = requireFeature ?
+        "<Require feature='opensocial-templates'/>" : "";
+    return "<Module>" + "<ModulePrefs title='Title'>"
+        + feature + "</ModulePrefs>"
+        + "<Content>"
+        + "    <![CDATA[" + content + "]]>"
+        + "</Content></Module>";
+  }
+}

Added: incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/templates/TemplateProcessorTest.java
URL: http://svn.apache.org/viewvc/incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/templates/TemplateProcessorTest.java?rev=744279&view=auto
==============================================================================
--- incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/templates/TemplateProcessorTest.java (added)
+++ incubator/shindig/trunk/java/gadgets/src/test/java/org/apache/shindig/gadgets/templates/TemplateProcessorTest.java Fri Feb 13 23:24:53 2009
@@ -0,0 +1,175 @@
+/*
+ * 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 static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+import org.apache.shindig.expressions.Expressions;
+import org.apache.shindig.expressions.RootELResolver;
+import org.apache.shindig.gadgets.GadgetException;
+import org.apache.shindig.gadgets.parse.ParseModule;
+import org.apache.shindig.gadgets.parse.nekohtml.NekoSerializer;
+import org.apache.shindig.gadgets.parse.nekohtml.SocialMarkupHtmlParser;
+import org.apache.shindig.gadgets.templates.TemplateContext;
+import org.apache.shindig.gadgets.templates.TemplateProcessor;
+
+import java.io.IOException;
+import java.util.Map;
+
+import javax.el.ELResolver;
+
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+import org.w3c.dom.Document;
+import org.w3c.dom.DocumentFragment;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import com.google.common.collect.Maps;
+
+/**
+ * Unit tests for TemplateProcessor.
+ * TODO: Refactor to remove boilerplate.
+ * TODO: Add tests for special vars.
+ * TODO: Add test for @var in @repeat loops. 
+ */
+public class TemplateProcessorTest {
+
+  private Expressions expressions;
+
+  private TemplateContext context;
+  private TemplateProcessor processor;
+  private Map<String, JSONObject> variables;
+  private ELResolver resolver;
+
+  private SocialMarkupHtmlParser parser;
+  
+  @Before
+  public void setUp() throws Exception {
+    expressions = new Expressions();
+    variables = Maps.newHashMap();
+    processor = new TemplateProcessor(expressions);
+    resolver = new RootELResolver();
+    parser = new SocialMarkupHtmlParser(new ParseModule.DOMImplementationProvider().get());    
+    context = new TemplateContext(variables);
+    
+    addVariable("foo", new JSONObject("{ title: 'bar' }"));
+    addVariable("user", new JSONObject("{ id: '101', name: { first: 'John', last: 'Doe' }}"));
+    addVariable("toys", new JSONObject("{ list: [{name: 'Ball'}, {name: 'Car'}]}"));
+    addVariable("xss", new JSONObject("{ script: '<script>alert();</script>'," +
+    		"quote:'\"><script>alert();</script>'}"));
+  }
+
+  @Test
+  public void testTextNode() throws Exception {
+    String output = executeTemplate("${foo.title}");
+    assertEquals("bar", output);
+  }
+  
+  @Test
+  public void testPlainText() throws Exception {
+    // Verify that plain text is not interfered with, or incorrectly escaped
+    String output = executeTemplate("<span>foo&amp;&bar</span>");
+    assertEquals("<span>foo&amp;&bar</span>", output);
+  }
+
+  @Test
+  public void testTextNodeEscaping() throws Exception {
+    String output = executeTemplate("${xss.script}");
+    assertFalse("Escaping not performed: \"" + output + "\"", output.contains("<script>alert("));
+  }
+  
+  @Test
+  public void testAppending() throws Exception {
+    String output = executeTemplate("${user.id}${user.name.first}");
+    assertEquals("101John", output);
+    
+    output = executeTemplate("foo${user.id}bar${user.name.first}baz");
+    assertEquals("foo101barJohnbaz", output);
+
+    output = executeTemplate("foo${user.nope}bar${user.nor}baz");
+    assertEquals("foobarbaz", output);
+  }
+  
+  @Test
+  public void testEscapedExpressions() throws Exception {
+    String output = executeTemplate("\\${escaped}");
+    assertEquals("${escaped}", output);
+
+    output = executeTemplate("foo\\${escaped}bar");
+    assertEquals("foo${escaped}bar", output);
+  }
+
+  @Test
+  public void testElement() throws Exception {
+    String output = executeTemplate("<span title=\"${user.id}\">${user.name.first} baz</span>");
+    assertEquals("<span title=\"101\">John baz</span>", output);
+  }
+
+  @Test
+  public void testAttributeEscaping() throws Exception {
+    String output = executeTemplate("<span title=\"${xss.quote}\">${user.name.first} baz</span>");
+    assertFalse(output.contains("\"><script>alert("));
+  }
+
+  @Test
+  public void testRepeat() throws Exception {
+    String output = executeTemplate("<span repeat=\"${toys}\">${name}</span>");
+    assertEquals("<span>Ball</span><span>Car</span>", output);
+  }
+  
+  @Test
+  public void testConditional() throws Exception {
+    String output = executeTemplate(
+        "<span repeat=\"${toys}\">" +
+          "<span if=\"${name == 'Car'}\">Car</span>" +
+          "<span if=\"${name != 'Car'}\">Not Car</span>" +
+        "</span>");
+    assertEquals("<span><span>Not Car</span></span><span><span>Car</span></span>", output);
+  }
+
+  private String executeTemplate(String markup) throws Exception {
+    Element template = prepareTemplate(markup);
+    DocumentFragment result = processor.processTemplate(template, context, resolver);
+    return serialize(result);
+  }
+  
+  private Element prepareTemplate(String markup) throws GadgetException {
+    String content = "<script type=\"text/os-template\">" + markup + "</script>";
+    Document document = parser.parseDom(content);
+    return (Element) document.getElementsByTagName("script").item(0);
+  }
+  
+  private String serialize(Node node) throws IOException {
+    StringBuilder sb = new StringBuilder();
+    NodeList children = node.getChildNodes();
+    for (int i = 0; i < children.getLength(); i++) {
+      Node child = children.item(i);
+      NekoSerializer.serialize(child, sb);
+    }
+    return sb.toString();
+  }
+  
+  private void addVariable(String key, JSONObject value) {
+    variables.put(key, value);
+  }
+}