package com.google.sitebricks.compiler;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.google.sitebricks.Renderable;
import com.google.sitebricks.Template;
import com.google.sitebricks.conversion.generics.Generics;
import com.google.sitebricks.rendering.Strings;
import com.google.sitebricks.rendering.control.Chains;
import com.google.sitebricks.rendering.control.WidgetChain;
import com.google.sitebricks.rendering.control.WidgetRegistry;
import com.google.sitebricks.routing.PageBook;
import com.google.sitebricks.routing.SystemMetrics;
import org.jetbrains.annotations.NotNull;
import org.jsoup.nodes.Attribute;
import org.jsoup.nodes.Attributes;
import org.jsoup.nodes.Comment;
import org.jsoup.nodes.DataNode;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import org.jsoup.nodes.XmlDeclaration;
import java.lang.reflect.Type;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Stack;
import static com.google.sitebricks.compiler.AnnotationNode.ANNOTATION;
import static com.google.sitebricks.compiler.AnnotationNode.ANNOTATION_CONTENT;
import static com.google.sitebricks.compiler.AnnotationNode.ANNOTATION_KEY;
import static com.google.sitebricks.compiler.HtmlParser.LINE_NUMBER_ATTRIBUTE;
import static com.google.sitebricks.compiler.HtmlParser.SKIP_ATTR;
/**
* @author Shawn based on XMLTemplateCompiler by Dhanji R. Prasanna (dhanji@gmail.com)
*/
@Singleton
public class HtmlTemplateCompiler implements TemplateCompiler {
private final WidgetRegistry registry;
private final PageBook pageBook;
private final SystemMetrics metrics;
//special widget types (built-in symbol table)
private static final String REQUIRE_WIDGET = "@require";
private static final String REPEAT_WIDGET = "repeat";
private static final String CHOOSE_WIDGET = "choose";
@Inject
public HtmlTemplateCompiler(WidgetRegistry registry, PageBook pageBook, SystemMetrics metrics) {
this.registry = registry;
this.pageBook = pageBook;
this.metrics = metrics;
}
//
// compiler state
//
class PageCompilingContext {
Class<?> page;
Template template;
List<CompileError> errors = Lists.newArrayList();
List<CompileError> warnings = Lists.newArrayList();
Element form;
Stack<EvaluatorCompiler> lexicalScopes = new Stack<EvaluatorCompiler>();
}
public Renderable compile(Class<?> page, Template template) {
PageCompilingContext pc = new PageCompilingContext();
pc.page = page;
pc.template = template;
pc.lexicalScopes.push(new MvelEvaluatorCompiler(page));
WidgetChain widgetChain;
widgetChain = walk(pc, HtmlParser.parse(template.getText()));
// TODO - get the errors when !(isValid)
if (!pc.errors.isEmpty() || !pc.warnings.isEmpty()) {
// If there were any errors we must track them.
metrics.logErrorsAndWarnings(page, pc.errors, pc.warnings);
// Only explode if there are errors.
if (!pc.errors.isEmpty())
throw new TemplateCompileException(page, template.getText(), pc.errors, pc.warnings);
}
return widgetChain;
}
private WidgetChain walk(PageCompilingContext pc, List<Node> nodes) {
WidgetChain chain = Chains.proceeding();
for (Node n: nodes)
chain.addWidget(widgetize(pc, n, walk(pc, n)));
return chain;
}
/**
* Walks the DOM recursively, and converts elements into corresponding sitebricks widgets.
*/
@NotNull
private <N extends Node> WidgetChain walk(PageCompilingContext pc, N node) {
WidgetChain widgetChain = Chains.proceeding();
for (Node n: node.childNodes()) {
if (n instanceof Element) {
final Element child = (Element) n;
//push form if this is a form tag
if (child.tagName().equals("form"))
pc.form = (Element) n;
//setup a lexical scope if we're going into a repeat widget (by reading the previous node)
final boolean shouldPopScope = lexicalClimb(pc, child);
//continue recursing down, perform a post-order, depth-first traversal of the DOM
WidgetChain childsChildren;
try {
childsChildren = walk(pc, child);
//process the widget itself into a Renderable with child tree
widgetChain.addWidget(widgetize(pc, child, childsChildren));
} finally {
lexicalDescend(pc, child, shouldPopScope);
}
} else if (n instanceof TextNode) {
TextNode child = (TextNode)n;
Renderable textWidget;
//setup a lexical scope if we're going into a repeat widget (by reading the previous node)
final boolean shouldPopScope = lexicalClimb(pc, child);
// construct the text widget
try {
textWidget = registry.textWidget(cleanHtml(n), pc.lexicalScopes.peek());
// if there are no annotations, add the text widget to the chain
if (!child.hasAttr(ANNOTATION_KEY)) {
widgetChain.addWidget(textWidget);
}
else {
// construct a new widget chain for this text node
WidgetChain childsChildren = Chains.proceeding().addWidget(textWidget);
// make a new widget for the annotation, making the text chain the child
String widgetName = child.attr(ANNOTATION_KEY).toLowerCase();
Renderable annotationWidget = registry.newWidget(widgetName, child.attr(ANNOTATION_CONTENT), childsChildren, pc.lexicalScopes.peek());
widgetChain.addWidget(annotationWidget);
}
} catch (ExpressionCompileException e) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(line(n))
.causedBy(e)
);
}
if (shouldPopScope)
pc.lexicalScopes.pop();
} else if ((n instanceof Comment) || (n instanceof DataNode)) {
//process as raw text widget
try {
widgetChain.addWidget(registry.textWidget(cleanHtml(n), pc.lexicalScopes.peek()));
} catch (ExpressionCompileException e) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(line(node))
.causedBy(e)
);
}
} else if (n instanceof XmlDeclaration) {
try {
widgetChain.addWidget(registry
.xmlDirectiveWidget(((XmlDeclaration)n).getWholeDeclaration(),
pc.lexicalScopes.peek()));
} catch (ExpressionCompileException e) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(line(node))
.causedBy(e)
);
}
}
}
//return computed chain, or a terminal
return widgetChain;
}
/**
* Complement of HtmlTemplateCompiler#lexicalClimb().
* This method pops off the stack of lexical scopes when
* we're done processing a sitebricks widget.
*/
private void lexicalDescend(PageCompilingContext pc, Element element, boolean shouldPopScope) {
//pop form
if ("form".equals(element.tagName()))
pc.form = null;
//pop compiler if the scope ends
if (shouldPopScope) {
pc.lexicalScopes.pop();
}
}
/**
* Called to push a new lexical scope onto the stack.
*/
private boolean lexicalClimb(PageCompilingContext pc, Node node) {
if (node.attr(ANNOTATION).length()>1) {
// Setup a new lexical scope (symbol table changes on each scope encountered).
if (REPEAT_WIDGET.equalsIgnoreCase(node.attr(ANNOTATION_KEY))
|| CHOOSE_WIDGET.equalsIgnoreCase(node.attr(ANNOTATION_KEY))) {
String[] keyAndContent = {node.attr(ANNOTATION_KEY), node.attr(ANNOTATION_CONTENT)};
pc.lexicalScopes.push(new MvelEvaluatorCompiler(parseRepeatScope(pc, keyAndContent, node)));
return true;
}
// Setup a new lexical scope for compiling against embedded pages (closures).
final PageBook.Page embed = pageBook.forName(node.attr(ANNOTATION_KEY));
if (null != embed) {
final Class<?> embedClass = embed.pageClass();
MvelEvaluatorCompiler compiler = new MvelEvaluatorCompiler(embedClass);
checkEmbedAgainst(pc, compiler, Parsing.toBindMap(node.attr(ANNOTATION_CONTENT)),
embedClass, node);
pc.lexicalScopes.push(compiler);
return true;
}
}
return false;
}
/**
* This method converts an XML element into a specific kind of widget.
* Special cases are the XML widget, Header, @Require widget. Otherwise a standard
* widget is created.
*/
@SuppressWarnings({"JavaDoc"}) @NotNull
private <N extends Node> Renderable widgetize(PageCompilingContext pc, N node, WidgetChain childsChildren) {
if (node instanceof XmlDeclaration) {
try {
XmlDeclaration decl = (XmlDeclaration)node;
return registry.xmlDirectiveWidget(decl.getWholeDeclaration(), pc.lexicalScopes.peek());
} catch (ExpressionCompileException e) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(line(node))
.causedBy(e)
);
}
}
// Header widget is a special case, where we match by the name of the tag =(
if ("head".equals(node.nodeName())) {
try {
return registry.headWidget(childsChildren, parseAttribs(node.attributes()), pc.lexicalScopes.peek());
} catch (ExpressionCompileException e) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(line(node))
.causedBy(e)
);
}
}
String annotation = node.attr(ANNOTATION);
//if there is no annotation, treat as a raw xml-widget (i.e. tag)
if ((null == annotation) || 0 == annotation.trim().length())
try {
checkUriConsistency(pc, node);
checkFormFields(pc, node);
return registry.xmlWidget(childsChildren, node.nodeName(), parseAttribs(node.attributes()),
pc.lexicalScopes.peek());
} catch (ExpressionCompileException e) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(line(node))
.causedBy(e)
);
return Chains.terminal();
}
// Special case: is this annotated with @Require
// if so, tags in head need to be promoted to head of enclosing page.
if (REQUIRE_WIDGET.equalsIgnoreCase(annotation.trim()))
try {
return registry.requireWidget(registry.xmlWidget(childsChildren, node.nodeName(),
parseAttribs(node.attributes()), pc.lexicalScopes.peek()));
} catch (ExpressionCompileException e) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(line(node))
.causedBy(e)
);
return Chains.terminal();
}
// If this is NOT a self-rendering widget, give it a child.
// final String widgetName = node.attr(ANNOTATION_KEY).trim().toLowerCase());
final String widgetName = node.attr(ANNOTATION_KEY).toLowerCase();
if (!registry.isSelfRendering(widgetName))
try {
childsChildren = Chains.singleton(registry.xmlWidget(childsChildren, node.nodeName(),
parseAttribs(node.attributes()), pc.lexicalScopes.peek()));
} catch (ExpressionCompileException e) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(line(node))
.causedBy(e)
);
}
// Recursively build widget from [Key, expression, child widgets].
try {
return registry.newWidget(widgetName, node.attr(ANNOTATION_CONTENT), childsChildren, pc.lexicalScopes.peek());
} catch (ExpressionCompileException e) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(line(node))
.causedBy(e)
);
// This should never be used.
return Chains.terminal();
}
}
private Map<String,Type> parseRepeatScope(PageCompilingContext pc, String[] extract, Node node) {
RepeatToken repeat = registry.parseRepeat(extract[1]);
Map<String, Type> context = Maps.newHashMap();
// Verify that @Repeat was parsed correctly.
if (null == repeat.var()) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(node.siblingIndex()) // TODO - line number
.causedBy(CompileErrors.MISSING_REPEAT_VAR)
);
}
if (null == repeat.items()) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(node.siblingIndex()) // TODO - line number
.causedBy(CompileErrors.MISSING_REPEAT_ITEMS)
);
}
try {
Type egressType = pc.lexicalScopes.peek().resolveEgressType(repeat.items());
// convert to collection if we need to
Type elementType;
Class<?> egressClass = Generics.erase(egressType);
if (egressClass.isArray()) {
elementType = Generics.getArrayComponentType(egressType);
}
else if (Collection.class.isAssignableFrom(egressClass)) {
elementType = Generics.getTypeParameter(egressType, Collection.class.getTypeParameters()[0]);
}
else {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(node.siblingIndex()) // TODO - line number
.causedBy(CompileErrors.REPEAT_OVER_ATOM)
);
return Collections.emptyMap();
}
context.put(repeat.var(), elementType);
context.put(repeat.pageVar(), pc.page);
context.put("__page", pc.page);
context.put("index", int.class);
context.put("isLast", boolean.class);
} catch (ExpressionCompileException e) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(node.siblingIndex()) // TODO - line number
.causedBy(e)
);
}
return context;
}
private void checkFormFields(PageCompilingContext pc, Node element) {
if (null == pc.form)
return;
String action = pc.form.attr("action");
// Only look at contextual uris (i.e. hosted by us).
// TODO - relative, not starting with '/'
if (null == action || (!action.startsWith("/")))
return;
final PageBook.Page page = pageBook.get(action);
// Only look at pages we actually have registered.
if (null == page) {
pc.warnings.add(
CompileError.in(element.outerHtml())
.near(line(element))
.causedBy(CompileErrors.UNRESOLVABLE_FORM_ACTION)
);
return;
}
// If we're inside a form do a throw-away compile against the target page.
if ("input".equals(element.nodeName()) || "textarea".equals(element.nodeName())) {
String name = element.attr("name");
// Skip submits and buttons.
if (skippable(element.attr("type")))
return;
//TODO Skip empty?
if (null == name) {
pc.warnings.add(
CompileError.in(element.outerHtml())
.near(line(element))
.causedBy(CompileErrors.FORM_MISSING_NAME)
);
return;
}
// Compile expression path.
try {
new MvelEvaluatorCompiler(page.pageClass())
.compile(name);
} catch (ExpressionCompileException e) {
//TODO Very hacky, needed to strip out xmlns attribution.
pc.warnings.add(
CompileError.in(element.outerHtml())
.near(element.siblingIndex()) // TODO - line number
.causedBy(CompileErrors.UNRESOLVABLE_FORM_BINDING, e)
);
}
}
}
private void checkUriConsistency(PageCompilingContext pc, Node element) {
String uriAttrib = element.attr("action");
if (null == uriAttrib)
uriAttrib = element.attr("src");
if (null == uriAttrib)
uriAttrib = element.attr("href");
if (null != uriAttrib) {
// Verify that such a uri exists in the page book,
// only if it is contextual--ignore abs & relative URIs.
if (uriAttrib.startsWith("/"))
if (null == pageBook.nonCompilingGet(uriAttrib))
pc.warnings.add(
CompileError.in(element.outerHtml())
.near(element.siblingIndex()) // TODO - line number
.causedBy(CompileErrors.UNRESOLVABLE_FORM_ACTION, uriAttrib)
);
}
}
/**
* @param attributes A list of attribs
* @return Returns a mutable map parsed out of the attribute list
*/
public static Map<String, String> parseAttribs(Attributes attributes) {
Map<String, String> attrs = new LinkedHashMap<String, String>(attributes.size() + 4);
for (Attribute a : attributes.asList())
if (!SKIP_ATTR.contains(a.getKey()))
attrs.put(a.getKey(), a.getValue());
return attrs;
}
// Ensures that embed bound properties are writable
private void checkEmbedAgainst(PageCompilingContext pc, EvaluatorCompiler compiler, Map<String, String> properties, Class<?> embedClass, Node node) {
// TODO also type check them against expressions
for (String property : properties.keySet()) {
try {
if (!compiler.isWritable(property)) {
pc.errors.add(
CompileError.in(node.outerHtml())
//TODO we need better line number detection if there is whitespace between the annotation and tag.
.near(node.siblingIndex()-1) // TODO - line number of the annotation
.causedBy(CompileErrors.PROPERTY_NOT_WRITEABLE,
String.format("Property %s#%s was not writable. Did you forget to create "
+ "a setter or @Visible annotation?", embedClass.getSimpleName(), property))
);
}
} catch (ExpressionCompileException ece) {
pc.errors.add(
CompileError.in(node.outerHtml())
.near(node.siblingIndex()) // TODO - line number
.causedBy(CompileErrors.ERROR_COMPILING_PROPERTY)
);
}
}
}
static boolean skippable(String kind) {
return null != kind && ("submit".equals(kind)
|| "button".equals(kind)
|| "reset".equals(kind)
|| "file".equals(kind));
}
// TESTING jsoup.nodes.Node
public List<Node> findSiblings(Node node) {
Preconditions.checkNotNull(node);
Node parent = node.parent();
if (null == parent) return null;
return parent.childNodes();
}
protected static <N extends Node> Integer indexInList(N search, List<N> nodes) {
Preconditions.checkNotNull(search);
Preconditions.checkNotNull(nodes);
for (int i = 0; i < nodes.size(); i++) {
N node = nodes.get(i);
if (node.equals(search))
return i;
}
return null;
}
private static int line(Node node) {
return Integer.valueOf(node.attr(LINE_NUMBER_ATTRIBUTE));
}
// outerHtml from jsoup.Node, Element with suppressed _attribs
private static String cleanHtml(final Node node) {
if (node instanceof Element) {
Element element = ((Element) node);
StringBuilder accum = new StringBuilder();
accum.append("<").append(element.tagName());
for (Attribute attribute: element.attributes()) {
if (!(attribute.getKey().startsWith("_"))) {
accum.append(" ");
accum.append(attribute.getKey());
accum.append("=\"");
accum.append(attribute.getValue());
accum.append('"');
}
}
if (element.childNodes().isEmpty() && element.tag().isEmpty()) {
accum.append(" />");
} else {
accum.append(">");
for (Node child : element.childNodes())
accum.append(cleanHtml(child));
accum.append("</").append(element.tagName()).append(">");
}
return accum.toString();
} else if (node instanceof TextNode) {
return ((TextNode) node).getWholeText();
} else if (node instanceof XmlDeclaration) {
// HACK
if (node.childNodes().isEmpty()) {
return "";
}
return node.outerHtml();
} else if (node instanceof Comment) {
// HACK: elide comments for now.
return "";
} else if (node instanceof DataNode && node.childNodes().isEmpty()) {
// No child nodes are defined but we have to handle content if such exists, example
// <script language="JavaScript">var a = { name: "${user.name}"}</script>
String content = node.attr("data");
if (Strings.empty(content)) {
return "";
}
return content;
} else {
return node.outerHtml();
}
}
}