rdftemplate

Library for generating XML documents from RDF data using templates
git clone https://code.djc.id.au/git/rdftemplate/
commit 27c21cec0e8271e4c56b5746d8a04ca644ab8f78
Author: Dan Callaghan <djc@djc.id.au>
Date:   Wed, 30 Sep 2009 21:01:10 +1000

library for RDF templates

Diffstat:
Apom.xml | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/antlr3/au/com/miskinhill/rdftemplate/selector/Selector.g | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/NamespacePrefixMapper.java | 41+++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/TemplateInterpolator.java | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/TemplateSyntaxException.java | 11+++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/datatype/DateDataType.java | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/AbstractSelector.java | 41+++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/Adaptation.java | 11+++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/ComparableLiteralValueAdaptation.java | 26++++++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/InvalidSelectorSyntaxException.java | 13+++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/LiteralValueAdaptation.java | 21+++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/NoopSelector.java | 19+++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/Predicate.java | 7+++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/Selector.java | 17+++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/SelectorEvaluationException.java | 11+++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/SelectorWithAdaptation.java | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/Traversal.java | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/TraversingSelector.java | 46++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/UriAdaptation.java | 22++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/UriPrefixPredicate.java | 26++++++++++++++++++++++++++
Asrc/main/java/au/com/miskinhill/rdftemplate/selector/UriSliceAdaptation.java | 32++++++++++++++++++++++++++++++++
Asrc/test/java/au/com/miskinhill/rdftemplate/TemplateInterpolatorUnitTest.java | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/test/java/au/com/miskinhill/rdftemplate/selector/AdaptationMatcher.java | 30++++++++++++++++++++++++++++++
Asrc/test/java/au/com/miskinhill/rdftemplate/selector/BeanPropertyMatcher.java | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/test/java/au/com/miskinhill/rdftemplate/selector/PredicateMatcher.java | 17+++++++++++++++++
Asrc/test/java/au/com/miskinhill/rdftemplate/selector/SelectorEvaluationUnitTest.java | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/test/java/au/com/miskinhill/rdftemplate/selector/SelectorMatcher.java | 30++++++++++++++++++++++++++++++
Asrc/test/java/au/com/miskinhill/rdftemplate/selector/SelectorParserUnitTest.java | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/test/java/au/com/miskinhill/rdftemplate/selector/TraversalMatcher.java | 46++++++++++++++++++++++++++++++++++++++++++++++
Asrc/test/resources/au/com/miskinhill/rdftemplate/replace-subtree.xml | 11+++++++++++
Asrc/test/resources/au/com/miskinhill/rdftemplate/test-data.xml | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/test/resources/au/com/miskinhill/rdftemplate/test-template.out.xml | 47+++++++++++++++++++++++++++++++++++++++++++++++
Asrc/test/resources/au/com/miskinhill/rdftemplate/test-template.xml | 43+++++++++++++++++++++++++++++++++++++++++++
33 files changed, 1806 insertions(+), 0 deletions(-)
diff --git a/pom.xml b/pom.xml
@@ -0,0 +1,76 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    
+    <groupId>au.com.miskinhill</groupId>
+    <artifactId>rdftemplate</artifactId>
+    <packaging>jar</packaging>
+    <version>1.0-SNAPSHOT</version>
+    <name>rdftemplate</name>
+    <url>http://code.miskinhill.com.au/</url>
+
+    <build>
+        <plugins>
+            <plugin>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <configuration>
+                    <source>1.6</source>
+                    <target>1.6</target>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.antlr</groupId>
+                <artifactId>antlr3-maven-plugin</artifactId>
+                <version>3.1.3-1</version>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>antlr</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+    
+    <dependencies>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.5</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+        	<groupId>commons-lang</groupId>
+        	<artifactId>commons-lang</artifactId>
+        	<version>2.4</version>
+        </dependency>
+        <dependency>
+            <groupId>net.sourceforge.collections</groupId>
+            <artifactId>collections-generic</artifactId>
+            <version>4.01</version>
+        </dependency>
+        <dependency>
+        	<groupId>commons-beanutils</groupId>
+        	<artifactId>commons-beanutils</artifactId>
+        	<version>1.8.0</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+        	<groupId>org.antlr</groupId>
+        	<artifactId>antlr-runtime</artifactId>
+        	<version>3.1.3</version>
+        </dependency>
+        <dependency>
+            <groupId>com.hp.hpl.jena</groupId>
+            <artifactId>jena</artifactId>
+            <version>2.5.7</version>
+        </dependency>
+        <dependency>
+        	<groupId>joda-time</groupId>
+        	<artifactId>joda-time</artifactId>
+        	<version>1.6</version>
+        </dependency>
+    </dependencies>
+    
+</project>
diff --git a/src/main/antlr3/au/com/miskinhill/rdftemplate/selector/Selector.g b/src/main/antlr3/au/com/miskinhill/rdftemplate/selector/Selector.g
@@ -0,0 +1,113 @@
+grammar Selector;
+
+@parser::header {
+package au.com.miskinhill.rdftemplate.selector;
+}
+
+@parser::members {
+    public static Selector<?> parse(String expression) {
+        CharStream stream = new ANTLRStringStream(expression);
+        SelectorLexer lexer = new SelectorLexer(stream);
+        CommonTokenStream tokens = new CommonTokenStream(lexer);
+        SelectorParser parser = new SelectorParser(tokens);
+        try {
+            return parser.selector();
+        } catch (RecognitionException e) {
+            throw new InvalidSelectorSyntaxException(e);
+        }
+    }
+    
+    @Override
+    public void reportError(RecognitionException e) {
+        throw new InvalidSelectorSyntaxException(e);
+    }
+}
+
+@lexer::header {
+package au.com.miskinhill.rdftemplate.selector;
+}
+
+@lexer::members {
+    @Override
+    public void reportError(RecognitionException e) {
+        throw new InvalidSelectorSyntaxException(e);
+    }
+}
+
+start : selector ;
+
+selector returns [Selector<?> result]
+    : ( ts=traversingSelector { result = ts; }
+      | { result = new NoopSelector(); }
+      )
+      ( '#'
+        ( URI_ADAPTATION { result = new SelectorWithAdaptation(result, new UriAdaptation()); }
+        | URI_SLICE_ADAPTATION
+          '('
+          startIndex=INTEGER { result = new SelectorWithAdaptation(result,
+                                       new UriSliceAdaptation(Integer.parseInt($startIndex.text))); }
+          ')'
+        | COMPARABLE_LV_ADAPTATION { result = new SelectorWithAdaptation(result, new ComparableLiteralValueAdaptation()); }
+        | LV_ADAPTATION { result = new SelectorWithAdaptation(result, new LiteralValueAdaptation()); }
+        )
+      |
+      )
+    ;
+
+traversingSelector returns [TraversingSelector result]
+@init {
+    result = new TraversingSelector();
+}
+    : t=traversal { $result.addTraversal(t); }
+      ( '/'
+        t=traversal { $result.addTraversal(t); }
+      ) *
+    ;
+    
+traversal returns [Traversal result]
+@init {
+    result = new Traversal();
+}
+    : ( '!' { $result.setInverse(true); }
+      | // optional
+      )
+      nsprefix=XMLTOKEN { $result.setPropertyNamespacePrefix($nsprefix.text); }
+      ':'
+      localname=XMLTOKEN { $result.setPropertyLocalName($localname.text); }
+      ( '['
+        URI_PREFIX_PREDICATE
+        '='
+        uriPrefix=SINGLE_QUOTED { $result.setPredicate(new UriPrefixPredicate($uriPrefix.text)); }
+        ']'
+      | // optional
+      )
+      ( '('
+        ( '~' { $result.setReverseSorted(true); }
+        | // optional
+        )
+        s=selector { $result.setSortOrder(s.withResultType(Comparable.class)); }
+        ')'
+      | // optional
+      )
+      ( '['
+        subscript=INTEGER { $result.setSubscript(Integer.parseInt($subscript.text)); }
+        ']'
+      | // optional
+      )
+    ;
+
+URI_PREFIX_PREDICATE : 'uri-prefix' ;
+FIRST_PREDICATE : 'first' ;
+LV_ADAPTATION : 'lv' ;
+COMPARABLE_LV_ADAPTATION : 'comparable-lv' ;
+URI_SLICE_ADAPTATION : 'uri-slice' ;
+URI_ADAPTATION : 'uri' ;
+
+XMLTOKEN : ('a'..'z'|'A'..'Z') ('a'..'z'|'A'..'Z'|'0'..'9'|'_'|'-')* ;
+INTEGER : ('0'..'9')+ ;
+SINGLE_QUOTED : '\'' ( options {greedy=false;} : . )* '\''
+    {
+        // strip quotes
+        String txt = getText();
+        setText(txt.substring(1, txt.length() -1));
+    };
+\ No newline at end of file
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/NamespacePrefixMapper.java b/src/main/java/au/com/miskinhill/rdftemplate/NamespacePrefixMapper.java
@@ -0,0 +1,41 @@
+package au.com.miskinhill.rdftemplate;
+
+import java.util.HashMap;
+
+import com.hp.hpl.jena.sparql.vocabulary.FOAF;
+import com.hp.hpl.jena.vocabulary.DCTerms;
+import com.hp.hpl.jena.vocabulary.DC_11;
+import com.hp.hpl.jena.vocabulary.OWL;
+import com.hp.hpl.jena.vocabulary.RDF;
+import com.hp.hpl.jena.vocabulary.RDFS;
+import com.hp.hpl.jena.vocabulary.XSD;
+
+public final class NamespacePrefixMapper extends HashMap<String, String> {
+    
+    private static final long serialVersionUID = 2119318190108418682L;
+    
+    private static final NamespacePrefixMapper instance = new NamespacePrefixMapper();
+    public static NamespacePrefixMapper getInstance() {
+        return instance;
+    }
+    
+    private NamespacePrefixMapper() {
+        put("mhs", "http://miskinhill.com.au/rdfschema/1.0/");
+        put("dc", DCTerms.NS);
+        put("old-dc", DC_11.NS);
+        put("foaf", FOAF.NS);
+        put("rdf", RDF.getURI());
+        put("rdfs", RDFS.getURI());
+        put("xs", XSD.getURI());
+        put("xsd", "http://www.w3.org/TR/xmlschema-2/#");
+        put("contact", "http://www.w3.org/2000/10/swap/pim/contact#");
+        put("geonames", "http://www.geonames.org/ontology#");
+        put("sioc", "http://rdfs.org/sioc/ns#");
+        put("awol", "http://bblfish.net/work/atom-owl/2006-06-06/#");
+        put("lingvoj", "http://www.lingvoj.org/ontology#");
+        put("prism", "http://prismstandard.org/namespaces/1.2/basic/");
+        put("owl", OWL.NS);
+        put("rev", "http://purl.org/stuff/rev#");
+    }
+    
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/TemplateInterpolator.java b/src/main/java/au/com/miskinhill/rdftemplate/TemplateInterpolator.java
@@ -0,0 +1,233 @@
+package au.com.miskinhill.rdftemplate;
+
+import java.io.Reader;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.xml.XMLConstants;
+import javax.xml.namespace.QName;
+import javax.xml.stream.XMLEventFactory;
+import javax.xml.stream.XMLEventWriter;
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.events.Attribute;
+import javax.xml.stream.events.Characters;
+import javax.xml.stream.events.EndElement;
+import javax.xml.stream.events.StartElement;
+import javax.xml.stream.events.XMLEvent;
+
+import com.hp.hpl.jena.rdf.model.Literal;
+import com.hp.hpl.jena.rdf.model.RDFNode;
+import org.apache.commons.lang.StringUtils;
+
+import au.com.miskinhill.rdftemplate.selector.Selector;
+import au.com.miskinhill.rdftemplate.selector.SelectorParser;
+
+public class TemplateInterpolator {
+    
+    public static final String NS = "http://code.miskinhill.com.au/rdftemplate/";
+    public static final String CONTENT_ACTION = "content";
+    private static final QName CONTENT_ACTION_QNAME = new QName(NS, CONTENT_ACTION);
+    public static final String FOR_ACTION = "for";
+    private static final QName FOR_ACTION_QNAME = new QName(NS, FOR_ACTION);
+    private static final QName XML_LANG_QNAME = new QName(XMLConstants.XML_NS_URI, "lang", XMLConstants.XML_NS_PREFIX);
+    private static final String XHTML_NS_URI = "http://www.w3.org/1999/xhtml";
+    
+    private static final XMLInputFactory inputFactory = XMLInputFactory.newInstance();
+    private static final XMLOutputFactory outputFactory = XMLOutputFactory.newInstance();
+    private static final XMLEventFactory eventFactory = XMLEventFactory.newInstance();
+    static {
+        inputFactory.setProperty("javax.xml.stream.isCoalescing", true);
+    }
+    
+    private TemplateInterpolator() {
+    }
+    
+    @SuppressWarnings("unchecked")
+    public static String interpolate(Reader reader, RDFNode node) throws XMLStreamException {
+        StringWriter writer = new StringWriter();
+        interpolate(inputFactory.createXMLEventReader(reader), node, outputFactory.createXMLEventWriter(writer));
+        return writer.toString();
+    }
+    
+    public static void interpolate(Iterator<XMLEvent> reader, RDFNode node, XMLEventWriter writer)
+            throws XMLStreamException {
+        while (reader.hasNext()) {
+            XMLEvent event = reader.next();
+            switch (event.getEventType()) {
+                case XMLStreamConstants.START_ELEMENT: {
+                    StartElement start = (StartElement) event;
+                    Attribute contentAttribute = start.getAttributeByName(CONTENT_ACTION_QNAME);
+                    Attribute forAttribute = start.getAttributeByName(FOR_ACTION_QNAME);
+                    if (contentAttribute != null && forAttribute != null) {
+                        throw new TemplateSyntaxException("rdf:for and rdf:content cannot both be present on an element");
+                    } else if (contentAttribute != null) {
+                        consumeTree(start, reader);
+                        start = interpolateAttributes(start, node);
+                        Selector<?> selector = SelectorParser.parse(contentAttribute.getValue());
+                        writeTreeForContent(writer, start, selector.singleResult(node));
+                    } else if (forAttribute != null) {
+                        start = cloneStartWithAttributes(start, cloneAttributesWithout(start, FOR_ACTION_QNAME));
+                        List<XMLEvent> tree = consumeTree(start, reader);
+                        Selector<RDFNode> selector = SelectorParser.parse(forAttribute.getValue()).withResultType(RDFNode.class);
+                        for (RDFNode subNode : selector.result(node)) {
+                            interpolate(tree.iterator(), subNode, writer);
+                        }
+                    } else {
+                        start = interpolateAttributes(start, node);
+                        writer.add(start);
+                    }
+                    break;
+                }
+                case XMLStreamConstants.CHARACTERS: {
+                    Characters characters = (Characters) event;
+                    writer.add(eventFactory.createCharacters(interpolateString(characters.getData(), node)));
+                    break;
+                }
+                case XMLStreamConstants.CDATA: {
+                    Characters characters = (Characters) event;
+                    writer.add(eventFactory.createCData(interpolateString(characters.getData(), node)));
+                    break;
+                }
+                default:
+                    writer.add(event);
+            }
+        }
+    }
+    
+    private static List<XMLEvent> consumeTree(StartElement start, Iterator<XMLEvent> reader) throws XMLStreamException {
+        List<XMLEvent> events = new ArrayList<XMLEvent>();
+        events.add(start);
+        Deque<QName> elementStack = new LinkedList<QName>();
+        while (reader.hasNext()) {
+            XMLEvent event = reader.next();
+            events.add(event);
+            switch (event.getEventType()) {
+                case XMLStreamConstants.START_ELEMENT:
+                    elementStack.addLast(((StartElement) event).getName());
+                    break;
+                case XMLStreamConstants.END_ELEMENT:
+                    if (elementStack.isEmpty()) {
+                        return events;
+                    } else {
+                        if (!elementStack.removeLast().equals(((EndElement) event).getName()))
+                            throw new IllegalStateException("End element mismatch");
+                    }
+                    break;
+                default:
+            }
+        }
+        throw new IllegalStateException("Reader exhausted before end element found");
+    }
+    
+    @SuppressWarnings("unchecked")
+    private static StartElement interpolateAttributes(StartElement start, RDFNode node) {
+        Set<Attribute> replacementAttributes = new LinkedHashSet<Attribute>();
+        for (Iterator<Attribute> it = start.getAttributes(); it.hasNext(); ) {
+            Attribute attribute = it.next();
+            String replacementValue = attribute.getValue();
+            if (!attribute.getName().getNamespaceURI().equals(NS)) // skip rdf: attributes
+                replacementValue = interpolateString(attribute.getValue(), node); 
+            replacementAttributes.add(eventFactory.createAttribute(attribute.getName(),
+                    replacementValue));
+        }
+        return cloneStartWithAttributes(start, replacementAttributes);
+    }
+    
+    private static StartElement cloneStartWithAttributes(StartElement start, Iterable<Attribute> attributes) {
+        return eventFactory.createStartElement(
+                start.getName().getPrefix(),
+                start.getName().getNamespaceURI(),
+                start.getName().getLocalPart(),
+                attributes.iterator(),
+                start.getNamespaces(),
+                start.getNamespaceContext());
+    }
+    
+    private static final Pattern SUBSTITUTION_PATTERN = Pattern.compile("\\$\\{([^}]*)\\}");
+    public static String interpolateString(String template, RDFNode node) {
+        if (!SUBSTITUTION_PATTERN.matcher(template).find()) {
+            return template; // fast path
+        }
+        StringBuffer substituted = new StringBuffer();
+        Matcher matcher = SUBSTITUTION_PATTERN.matcher(template);
+        while (matcher.find()) {
+            String expression = matcher.group(1);
+            Object replacement = SelectorParser.parse(expression).singleResult(node);
+            
+            String replacementValue;
+            if (replacement instanceof RDFNode) {
+                RDFNode replacementNode = (RDFNode) replacement;
+                if (replacementNode.isLiteral()) {
+                    Literal replacementLiteral = (Literal) replacementNode;
+                    replacementValue = replacementLiteral.getValue().toString();
+                } else {
+                    throw new UnsupportedOperationException("Not a literal: " + replacementNode);
+                }
+            } else if (replacement instanceof String) {
+                replacementValue = (String) replacement;
+            } else {
+                throw new UnsupportedOperationException("Not an RDFNode: " + replacement);
+            }
+            
+            matcher.appendReplacement(substituted, replacementValue.replace("$", "\\$"));;
+        }
+        matcher.appendTail(substituted);
+        return substituted.toString();
+    }
+    
+    private static void writeTreeForContent(XMLEventWriter writer, StartElement start, Object replacement)
+            throws XMLStreamException {
+        if (replacement instanceof RDFNode) {
+            RDFNode replacementNode = (RDFNode) replacement;
+            if (replacementNode.isLiteral()) {
+                Literal literal = (Literal) replacementNode;
+                Set<Attribute> attributes = cloneAttributesWithout(start, CONTENT_ACTION_QNAME);
+                
+                if (!StringUtils.isEmpty(literal.getLanguage())) {
+                    attributes.add(eventFactory.createAttribute(XML_LANG_QNAME, literal.getLanguage()));
+                    if (start.getName().getNamespaceURI().equals(XHTML_NS_URI)) {
+                        String xhtmlPrefixInContext = start.getNamespaceContext().getPrefix(XHTML_NS_URI);
+                        QName xhtmlLangQNameForContext; // ugh
+                        if (xhtmlPrefixInContext.isEmpty())
+                            xhtmlLangQNameForContext = new QName("lang");
+                        else
+                            xhtmlLangQNameForContext = new QName(XHTML_NS_URI, "lang", xhtmlPrefixInContext);
+                        attributes.add(eventFactory.createAttribute(xhtmlLangQNameForContext, literal.getLanguage()));
+                    }
+                }
+                
+                writer.add(eventFactory.createStartElement(start.getName(), attributes.iterator(), start.getNamespaces()));
+                writer.add(eventFactory.createCharacters(literal.getValue().toString()));
+                writer.add(eventFactory.createEndElement(start.getName(), start.getNamespaces()));
+            } else {
+                throw new UnsupportedOperationException("Not a literal: " + replacementNode);
+            }
+        } else {
+            throw new UnsupportedOperationException("Not an RDFNode: " + replacement);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Set<Attribute> cloneAttributesWithout(StartElement start, QName omit) {
+        // clone attributes, but without rdf:content
+        Set<Attribute> attributes = new LinkedHashSet<Attribute>();
+        for (Iterator<Attribute> it = start.getAttributes(); it.hasNext(); ) {
+            Attribute attribute = it.next();
+            if (!attribute.getName().equals(omit))
+                attributes.add(attribute);
+        }
+        return attributes;
+    }
+    
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/TemplateSyntaxException.java b/src/main/java/au/com/miskinhill/rdftemplate/TemplateSyntaxException.java
@@ -0,0 +1,11 @@
+package au.com.miskinhill.rdftemplate;
+
+public class TemplateSyntaxException extends RuntimeException {
+
+    private static final long serialVersionUID = 6518982504570154030L;
+    
+    public TemplateSyntaxException(String message) {
+        super(message);
+    }
+    
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/datatype/DateDataType.java b/src/main/java/au/com/miskinhill/rdftemplate/datatype/DateDataType.java
@@ -0,0 +1,107 @@
+package au.com.miskinhill.rdftemplate.datatype;
+
+import java.util.Arrays;
+import java.util.List;
+
+import com.hp.hpl.jena.datatypes.DatatypeFormatException;
+import com.hp.hpl.jena.datatypes.RDFDatatype;
+import com.hp.hpl.jena.datatypes.TypeMapper;
+import com.hp.hpl.jena.graph.impl.LiteralLabel;
+import org.joda.time.LocalDate;
+import org.joda.time.format.DateTimeFormat;
+import org.joda.time.format.DateTimeFormatter;
+
+public class DateDataType implements RDFDatatype {
+    
+    public static final String URI = "http://www.w3.org/TR/xmlschema-2/#date";
+    
+    public static final DateDataType instance = new DateDataType();
+    public static void register() {
+        TypeMapper.getInstance().registerDatatype(instance);
+    }
+    
+    private final List<DateTimeFormatter> parsers = Arrays.asList(
+            DateTimeFormat.forPattern("yyyy"),
+            DateTimeFormat.forPattern("yyyy-mm"),
+            DateTimeFormat.forPattern("yyyy-mm-dd"));
+
+    private DateDataType() {
+    }
+
+    @Override
+    public String getURI() {
+        return URI;
+    }
+    
+    @Override
+    public Class<LocalDate> getJavaClass() {
+        return LocalDate.class;
+    }
+
+    @Override
+    public String unparse(Object value) {
+        LocalDate date = (LocalDate) value;
+        return date.toString();
+    }
+
+    @Override
+    public Object cannonicalise(Object value) {
+        return value;
+    }
+
+    @Override
+    public Object extendedTypeDefinition() {
+        return null;
+    }
+
+    @Override
+    public int getHashCode(LiteralLabel lit) {
+        return lit.getValue().hashCode();
+    }
+
+    @Override
+    public boolean isEqual(LiteralLabel left, LiteralLabel right) {
+        return left.getValue().equals(right.getValue());
+    }
+    
+    @Override
+    public LocalDate parse(String lexicalForm) throws DatatypeFormatException {
+        for (DateTimeFormatter parser: parsers) {
+            try {
+                return parser.parseDateTime(lexicalForm).toLocalDate();
+            } catch (IllegalArgumentException e) {
+                // pass
+            }
+        }
+        throw new DatatypeFormatException(lexicalForm, this, "No matching parsers found");
+    }
+
+    @Override
+    public boolean isValid(String lexicalForm) {
+        for (DateTimeFormatter parser: parsers) {
+            try {
+                parser.parseDateTime(lexicalForm);
+                return true;
+            } catch (IllegalArgumentException e) {
+                // pass
+            }
+        }
+        return false;
+    }
+
+    @Override
+    public boolean isValidLiteral(LiteralLabel lit) {
+        return lit.getDatatypeURI().equals(URI) && isValid(lit.getLexicalForm());
+    }
+
+    @Override
+    public boolean isValidValue(Object valueForm) {
+        return (valueForm instanceof LocalDate);
+    }
+
+    @Override
+    public RDFDatatype normalizeSubType(Object value, RDFDatatype dt) {
+        return dt;
+    }
+
+}
+\ No newline at end of file
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/AbstractSelector.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/AbstractSelector.java
@@ -0,0 +1,40 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import java.util.List;
+
+import com.hp.hpl.jena.rdf.model.RDFNode;
+
+public abstract class AbstractSelector<T> implements Selector<T> {
+    
+    private final Class<T> resultType;
+    
+    protected AbstractSelector(Class<T> resultType) {
+        this.resultType = resultType;
+    }
+    
+    @Override
+    public abstract List<T> result(RDFNode node);
+    
+    @Override
+    public T singleResult(RDFNode node) {
+        List<T> results = result(node);
+        if (results.size() != 1) {
+            throw new SelectorEvaluationException("Expected exactly one result but got " + results);
+        }
+        return results.get(0);
+    }
+    
+    @Override
+    public Class<T> getResultType() {
+        return resultType;
+    }
+    
+    @Override
+    public <Other> Selector<Other> withResultType(Class<Other> otherType) {
+        if (!otherType.isAssignableFrom(resultType)) {
+            throw new ClassCastException("Result type " + resultType + " incompatible with requested type " + otherType);
+        }
+        return (Selector<Other>) this;
+    }
+
+}
+\ No newline at end of file
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/Adaptation.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/Adaptation.java
@@ -0,0 +1,11 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import com.hp.hpl.jena.rdf.model.RDFNode;
+
+public interface Adaptation<T> {
+
+    Class<T> getDestinationType();
+    
+    T adapt(RDFNode node);
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/ComparableLiteralValueAdaptation.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/ComparableLiteralValueAdaptation.java
@@ -0,0 +1,26 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import com.hp.hpl.jena.rdf.model.Literal;
+import com.hp.hpl.jena.rdf.model.RDFNode;
+
+public class ComparableLiteralValueAdaptation implements Adaptation<Comparable> {
+    
+    @Override
+    public Class<Comparable> getDestinationType() {
+        return Comparable.class;
+    }
+
+    @Override
+    public Comparable<?> adapt(RDFNode node) {
+        if (!node.isLiteral()) {
+            throw new SelectorEvaluationException("Attempted to apply #comparable-lv to non-literal node " + node);
+        }
+        Object literalValue = ((Literal) node).getValue();
+        if (!(literalValue instanceof Comparable<?>)) {
+            throw new SelectorEvaluationException("Attempted to apply #comparable-lv to non-Comparable node " + node +
+                    " with literal value of type " + literalValue.getClass());
+        }
+        return (Comparable<?>) literalValue;
+    }
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/InvalidSelectorSyntaxException.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/InvalidSelectorSyntaxException.java
@@ -0,0 +1,13 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import org.antlr.runtime.RecognitionException;
+
+public class InvalidSelectorSyntaxException extends RuntimeException {
+    
+    private static final long serialVersionUID = 5805546105865617336L;
+
+    public InvalidSelectorSyntaxException(RecognitionException e) {
+        super(e);
+    }
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/LiteralValueAdaptation.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/LiteralValueAdaptation.java
@@ -0,0 +1,21 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import com.hp.hpl.jena.rdf.model.Literal;
+import com.hp.hpl.jena.rdf.model.RDFNode;
+
+public class LiteralValueAdaptation implements Adaptation<Object> {
+    
+    @Override
+    public Class<Object> getDestinationType() {
+        return Object.class;
+    }
+
+    @Override
+    public Object adapt(RDFNode node) {
+        if (!node.isLiteral()) {
+            throw new SelectorEvaluationException("Attempted to apply #comparable-lv to non-literal node " + node);
+        }
+        return ((Literal) node).getValue();
+    }
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/NoopSelector.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/NoopSelector.java
@@ -0,0 +1,19 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import java.util.Collections;
+import java.util.List;
+
+import com.hp.hpl.jena.rdf.model.RDFNode;
+
+public class NoopSelector extends AbstractSelector<RDFNode> {
+    
+    public NoopSelector() {
+        super(RDFNode.class);
+    }
+    
+    @Override
+    public List<RDFNode> result(RDFNode node) {
+        return Collections.singletonList(node);
+    }
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/Predicate.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/Predicate.java
@@ -0,0 +1,7 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import com.hp.hpl.jena.rdf.model.RDFNode;
+
+public interface Predicate extends org.apache.commons.collections15.Predicate<RDFNode> {
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/Selector.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/Selector.java
@@ -0,0 +1,17 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import java.util.List;
+
+import com.hp.hpl.jena.rdf.model.RDFNode;
+
+public interface Selector<T> {
+    
+    List<T> result(RDFNode node);
+    
+    T singleResult(RDFNode node);
+    
+    Class<T> getResultType();
+    
+    <Other> Selector<Other> withResultType(Class<Other> otherType);
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/SelectorEvaluationException.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/SelectorEvaluationException.java
@@ -0,0 +1,11 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+public class SelectorEvaluationException extends RuntimeException {
+
+    private static final long serialVersionUID = -398277800899471325L;
+    
+    public SelectorEvaluationException(String message) {
+        super(message);
+    }
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/SelectorWithAdaptation.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/SelectorWithAdaptation.java
@@ -0,0 +1,48 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import com.hp.hpl.jena.rdf.model.RDFNode;
+import org.apache.commons.lang.builder.ToStringBuilder;
+
+public class SelectorWithAdaptation<T> extends AbstractSelector<T> {
+    
+    private final Selector<RDFNode> baseSelector;
+    private final Adaptation<T> adaptation;
+    
+    public SelectorWithAdaptation(Selector<RDFNode> baseSelector, Adaptation<T> adaptation) {
+        super(adaptation.getDestinationType());
+        this.baseSelector = baseSelector;
+        this.adaptation = adaptation;
+    }
+    
+    @Override
+    public String toString() {
+        return new ToStringBuilder(this).append(baseSelector).append(adaptation).toString();
+    }
+    
+    @Override
+    public List<T> result(RDFNode node) {
+        List<RDFNode> baseResults = baseSelector.result(node);
+        List<T> results = new ArrayList<T>();
+        for (RDFNode resultNode: baseResults) {
+            results.add(adaptation.adapt(resultNode));
+        }
+        return results;
+    }
+    
+    @Override
+    public T singleResult(RDFNode node) {
+        return adaptation.adapt(baseSelector.singleResult(node));
+    }
+    
+    public Selector<RDFNode> getBaseSelector() {
+        return baseSelector;
+    }
+    
+    public Adaptation<T> getAdaptation() {
+        return adaptation;
+    }
+
+}
+\ No newline at end of file
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/Traversal.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/Traversal.java
@@ -0,0 +1,143 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.commons.collections15.CollectionUtils;
+
+import com.hp.hpl.jena.rdf.model.Property;
+import com.hp.hpl.jena.rdf.model.RDFNode;
+import com.hp.hpl.jena.rdf.model.ResIterator;
+import com.hp.hpl.jena.rdf.model.Resource;
+import com.hp.hpl.jena.rdf.model.StmtIterator;
+import org.apache.commons.lang.builder.ToStringBuilder;
+
+import au.com.miskinhill.rdftemplate.NamespacePrefixMapper;
+
+public class Traversal {
+    
+    private String propertyNamespacePrefix;
+    private String propertyLocalName;
+    private boolean inverse = false;
+    private Predicate predicate;
+    private Selector<? extends Comparable<?>> sortOrder;
+    private Comparator<RDFNode> _sortComparator;
+    private boolean reverseSorted = false;
+    private Integer subscript;
+    
+    public List<RDFNode> traverse(RDFNode node) {
+        if (!node.isResource()) {
+            throw new SelectorEvaluationException("Attempted to traverse non-resource node " + node);
+        }
+        Resource resource = (Resource) node;
+        Property property = resource.getModel().createProperty(
+                NamespacePrefixMapper.getInstance().get(propertyNamespacePrefix),
+                propertyLocalName);
+        List<RDFNode> destinations = new ArrayList<RDFNode>();
+        if (!inverse) {
+            for (StmtIterator it = resource.listProperties(property); it.hasNext(); ) {
+                destinations.add(it.nextStatement().getObject());
+            }
+        } else {
+            for (ResIterator it = resource.getModel().listResourcesWithProperty(property, node); it.hasNext(); ) {
+                destinations.add(it.nextResource());
+            }
+        }
+        if (_sortComparator != null)
+            Collections.sort(destinations, reverseSorted ? Collections.reverseOrder(_sortComparator) : _sortComparator);
+        CollectionUtils.filter(destinations, predicate);
+        if (subscript != null) {
+            if (destinations.size() <= subscript) {
+                throw new SelectorEvaluationException("Cannot apply subscript " + subscript + " to nodes " + destinations);
+            }
+            destinations = Collections.singletonList(destinations.get(subscript));
+        }
+        return destinations;
+    }
+    
+    @Override
+    public String toString() {
+        return new ToStringBuilder(this)
+                .append("propertyNamespacePrefix", propertyNamespacePrefix)
+                .append("propertyLocalName", propertyLocalName)
+                .append("inverse", inverse)
+                .append("predicate", predicate)
+                .append("sortOrder", sortOrder)
+                .append("reverseSorted", reverseSorted)
+                .append("subscript", subscript)
+                .toString();
+    }
+    
+    public String getPropertyLocalName() {
+        return propertyLocalName;
+    }
+    
+    public void setPropertyLocalName(String propertyLocalName) {
+        this.propertyLocalName = propertyLocalName;
+    }
+    
+    public String getPropertyNamespacePrefix() {
+        return propertyNamespacePrefix;
+    }
+    
+    public void setPropertyNamespacePrefix(String propertyNamespacePrefix) {
+        this.propertyNamespacePrefix = propertyNamespacePrefix;
+    }
+    
+    public boolean isInverse() {
+        return inverse;
+    }
+    
+    public void setInverse(boolean inverse) {
+        this.inverse = inverse;
+    }
+    
+    public Predicate getPredicate() {
+        return predicate;
+    }
+    
+    public void setPredicate(Predicate predicate) {
+        this.predicate = predicate;
+    }
+    
+    public Selector<?> getSortOrder() {
+        return sortOrder;
+    }
+    
+    private static final class SelectorComparator<T extends Comparable<T>> implements Comparator<RDFNode> {
+        private final Selector<T> selector;
+        public SelectorComparator(Selector<T> selector) {
+            this.selector = selector;
+        }
+        @Override
+        public int compare(RDFNode left, RDFNode right) {
+            T leftKey = selector.singleResult(left);
+            T rightKey = selector.singleResult(right);
+            return leftKey.compareTo(rightKey);
+        }
+    }
+    
+    public <T extends Comparable<T>> void setSortOrder(Selector<T> sortOrder) {
+        this.sortOrder = sortOrder;
+        this._sortComparator = new SelectorComparator<T>(sortOrder);
+    }
+    
+    public boolean isReverseSorted() {
+        return reverseSorted;
+    }
+    
+    public void setReverseSorted(boolean reverseSorted) {
+        this.reverseSorted = reverseSorted;
+    }
+    
+    public Integer getSubscript() {
+        return subscript;
+    }
+    
+    public void setSubscript(Integer subscript) {
+        this.subscript = subscript;
+    }
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/TraversingSelector.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/TraversingSelector.java
@@ -0,0 +1,46 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.hp.hpl.jena.rdf.model.RDFNode;
+import org.apache.commons.lang.builder.ToStringBuilder;
+
+public class TraversingSelector extends AbstractSelector<RDFNode> {
+    
+    private final List<Traversal> traversals = new ArrayList<Traversal>();
+    
+    public TraversingSelector() {
+        super(RDFNode.class);
+    }
+    
+    @Override
+    public List<RDFNode> result(RDFNode node) {
+        Set<RDFNode> current = Collections.singleton(node);
+        for (Traversal traversal: traversals) {
+            LinkedHashSet<RDFNode> destinationsUnion = new LinkedHashSet<RDFNode>();
+            for (RDFNode start: current) {
+                destinationsUnion.addAll(traversal.traverse(start));
+            }
+            current = destinationsUnion;
+        }
+        return new ArrayList<RDFNode>(current);
+    }
+    
+    @Override
+    public String toString() {
+        return new ToStringBuilder(this).append(traversals).toString();
+    }
+    
+    public List<Traversal> getTraversals() {
+        return traversals;
+    }
+    
+    public void addTraversal(Traversal traversal) {
+        traversals.add(traversal);
+    }
+    
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/UriAdaptation.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/UriAdaptation.java
@@ -0,0 +1,22 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import com.hp.hpl.jena.rdf.model.Resource;
+
+import com.hp.hpl.jena.rdf.model.RDFNode;
+
+public class UriAdaptation implements Adaptation<String> {
+    
+    @Override
+    public Class<String> getDestinationType() {
+        return String.class;
+    }
+    
+    @Override
+    public String adapt(RDFNode node) {
+        if (!node.isResource()) {
+            throw new SelectorEvaluationException("Attempted to apply #uri to non-resource node " + node);
+        }
+        return ((Resource) node).getURI();
+    }
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/UriPrefixPredicate.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/UriPrefixPredicate.java
@@ -0,0 +1,26 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import com.hp.hpl.jena.rdf.model.RDFNode;
+import com.hp.hpl.jena.rdf.model.Resource;
+
+public class UriPrefixPredicate implements Predicate {
+    
+    private final String prefix;
+    
+    public UriPrefixPredicate(String prefix) {
+        this.prefix = prefix;
+    }
+    
+    public String getPrefix() {
+        return prefix;
+    }
+    
+    @Override
+    public boolean evaluate(RDFNode node) {
+        if (!node.isResource()) {
+            throw new SelectorEvaluationException("Attempted to apply [uri-prefix] to non-resource node " + node);
+        }
+        return ((Resource) node).getURI().startsWith(prefix);
+    }
+
+}
diff --git a/src/main/java/au/com/miskinhill/rdftemplate/selector/UriSliceAdaptation.java b/src/main/java/au/com/miskinhill/rdftemplate/selector/UriSliceAdaptation.java
@@ -0,0 +1,32 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import com.hp.hpl.jena.rdf.model.RDFNode;
+import com.hp.hpl.jena.rdf.model.Resource;
+
+public class UriSliceAdaptation implements Adaptation<String> {
+    
+    private final Integer startIndex;
+    
+    public UriSliceAdaptation(Integer startIndex) {
+        this.startIndex = startIndex;
+    }
+    
+    public Integer getStartIndex() {
+        return startIndex;
+    }
+    
+    @Override
+    public Class<String> getDestinationType() {
+        return String.class;
+    }
+
+    @Override
+    public String adapt(RDFNode node) {
+        if (!node.isResource()) {
+            throw new SelectorEvaluationException("Attempted to apply #uri-slice to non-resource node " + node);
+        }
+        String uri = ((Resource) node).getURI();
+        return uri.substring(startIndex);
+    }
+
+}
diff --git a/src/test/java/au/com/miskinhill/rdftemplate/TemplateInterpolatorUnitTest.java b/src/test/java/au/com/miskinhill/rdftemplate/TemplateInterpolatorUnitTest.java
@@ -0,0 +1,74 @@
+package au.com.miskinhill.rdftemplate;
+
+import static org.hamcrest.CoreMatchers.not;
+import static org.junit.Assert.*;
+import static org.junit.matchers.JUnitMatchers.containsString;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.Charset;
+
+import com.hp.hpl.jena.rdf.model.Model;
+import com.hp.hpl.jena.rdf.model.ModelFactory;
+import com.hp.hpl.jena.rdf.model.Resource;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import au.com.miskinhill.rdftemplate.datatype.DateDataType;
+
+public class TemplateInterpolatorUnitTest {
+    
+    @BeforeClass
+    public static void ensureDatatypesRegistered() {
+        DateDataType.register();
+    }
+    
+    private Model model;
+    
+    @Before
+    public void setUp() {
+        model = ModelFactory.createDefaultModel();
+        InputStream stream = this.getClass().getResourceAsStream(
+                "/au/com/miskinhill/rdftemplate/test-data.xml");
+        model.read(stream, "");
+    }
+    
+    @Test
+    public void shouldReplaceSubtreesWithContent() throws Exception {
+        Resource journal = model.getResource("http://miskinhill.com.au/journals/test/");
+        String result = TemplateInterpolator.interpolate(
+                new InputStreamReader(this.getClass().getResourceAsStream("replace-subtree.xml")), journal);
+        assertThat(result, containsString("<div xml:lang=\"en\" lang=\"en\">A journal, you know, with some stuff in it</div>"));
+        assertThat(result, not(containsString("<p>This should all go <em>away</em>!</p>")));
+    }
+    
+    @Test
+    public void shouldWork() throws Exception {
+        Resource journal = model.getResource("http://miskinhill.com.au/journals/test/");
+        String result = TemplateInterpolator.interpolate(
+                new InputStreamReader(this.getClass().getResourceAsStream("test-template.xml")), journal);
+        String expected = exhaust(this.getClass().getResource("test-template.out.xml").toURI());
+        assertEquals(expected.trim(), result.trim());
+    }
+    
+    private String exhaust(URI file) throws IOException { // sigh
+        FileChannel channel = new FileInputStream(new File(file)).getChannel();
+        Charset charset = Charset.defaultCharset();
+        StringBuffer sb = new StringBuffer();
+        ByteBuffer b = ByteBuffer.allocate(8192);
+        while (channel.read(b) > 0) {
+            b.rewind();
+            sb.append(charset.decode(b));
+            b.flip();
+        }
+        return sb.toString();
+    }
+    
+}
diff --git a/src/test/java/au/com/miskinhill/rdftemplate/selector/AdaptationMatcher.java b/src/test/java/au/com/miskinhill/rdftemplate/selector/AdaptationMatcher.java
@@ -0,0 +1,30 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+
+import static org.hamcrest.CoreMatchers.equalTo;
+
+public class AdaptationMatcher<T extends Adaptation<?>> extends BeanPropertyMatcher<T> {
+    
+    private AdaptationMatcher(Class<T> type) {
+        super(type);
+    }
+    
+    public static AdaptationMatcher<UriAdaptation> uriAdaptation() {
+        return new AdaptationMatcher<UriAdaptation>(UriAdaptation.class);
+    }
+    
+    public static AdaptationMatcher<UriSliceAdaptation> uriSliceAdaptation(Integer startIndex) {
+        AdaptationMatcher<UriSliceAdaptation> m = new AdaptationMatcher<UriSliceAdaptation>(UriSliceAdaptation.class);
+        m.addRequiredProperty("startIndex", equalTo(startIndex));
+        return m;
+    }
+    
+    public static AdaptationMatcher<LiteralValueAdaptation> lvAdaptation() {
+        return new AdaptationMatcher<LiteralValueAdaptation>(LiteralValueAdaptation.class);
+    }
+    
+    public static AdaptationMatcher<ComparableLiteralValueAdaptation> comparableLVAdaptation() {
+        return new AdaptationMatcher<ComparableLiteralValueAdaptation>(ComparableLiteralValueAdaptation.class);
+    }
+
+}
diff --git a/src/test/java/au/com/miskinhill/rdftemplate/selector/BeanPropertyMatcher.java b/src/test/java/au/com/miskinhill/rdftemplate/selector/BeanPropertyMatcher.java
@@ -0,0 +1,56 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.commons.beanutils.PropertyUtils;
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+
+public class BeanPropertyMatcher<T> extends BaseMatcher<T> {
+    
+    private final Class<? extends T> matchedType;
+    private final Map<String, Matcher<?>> requiredProperties = new LinkedHashMap<String, Matcher<?>>();
+    
+    public BeanPropertyMatcher(Class<? extends T> type) {
+        this.matchedType = type;
+    }
+    
+    public void addRequiredProperty(String name, Matcher<?> matcher) {
+        requiredProperties.put(name, matcher);
+    }
+    
+    @Override
+    public boolean matches(Object arg0) {
+        if (!matchedType.isInstance(arg0))
+            return false;
+        for (Map.Entry<String, Matcher<?>> property: requiredProperties.entrySet()) {
+            try {
+                Object beanProperty = PropertyUtils.getProperty(arg0, property.getKey());
+                if (!property.getValue().matches(beanProperty))
+                    return false;
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+        }
+        return true;
+    }
+    
+    @Override
+    public void describeTo(Description desc) {
+        desc.appendText(matchedType.getName());
+        desc.appendText("[");
+        boolean first = true;
+        for (Map.Entry<String, Matcher<?>> property: requiredProperties.entrySet()) {
+            if (!first)
+                desc.appendText(",");
+            desc.appendText(property.getKey());
+            desc.appendText("=");
+            property.getValue().describeTo(desc);
+            first = false;
+        }
+        desc.appendText("]");
+    }
+
+}
diff --git a/src/test/java/au/com/miskinhill/rdftemplate/selector/PredicateMatcher.java b/src/test/java/au/com/miskinhill/rdftemplate/selector/PredicateMatcher.java
@@ -0,0 +1,17 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+
+public class PredicateMatcher<T extends Predicate> extends BeanPropertyMatcher<T> {
+    
+    private PredicateMatcher(Class<T> type) {
+        super(type);
+    }
+    
+    public static PredicateMatcher<UriPrefixPredicate> uriPrefixPredicate(String prefix) {
+        PredicateMatcher<UriPrefixPredicate> m = new PredicateMatcher<UriPrefixPredicate>(UriPrefixPredicate.class);
+        m.addRequiredProperty("prefix", equalTo(prefix));
+        return m;
+    }
+
+}
diff --git a/src/test/java/au/com/miskinhill/rdftemplate/selector/SelectorEvaluationUnitTest.java b/src/test/java/au/com/miskinhill/rdftemplate/selector/SelectorEvaluationUnitTest.java
@@ -0,0 +1,137 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import static org.junit.matchers.JUnitMatchers.hasItems;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.junit.Assert.assertThat;
+
+import java.io.InputStream;
+import java.util.List;
+
+import com.hp.hpl.jena.rdf.model.Literal;
+import com.hp.hpl.jena.rdf.model.Model;
+import com.hp.hpl.jena.rdf.model.ModelFactory;
+import com.hp.hpl.jena.rdf.model.RDFNode;
+import com.hp.hpl.jena.rdf.model.Resource;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import au.com.miskinhill.rdftemplate.datatype.DateDataType;
+
+public class SelectorEvaluationUnitTest {
+    
+    private Model m;
+    private Resource journal, issue, article, author, book, review, anotherReview, obituary, en, ru;
+    
+    @BeforeClass
+    public static void ensureDatatypesRegistered() {
+        DateDataType.register();
+    }
+    
+    @Before
+    public void setUp() {
+        m = ModelFactory.createDefaultModel();
+        InputStream stream = this.getClass().getResourceAsStream("/au/com/miskinhill/rdftemplate/test-data.xml");
+        m.read(stream, "");
+        journal = m.createResource("http://miskinhill.com.au/journals/test/");
+        issue = m.createResource("http://miskinhill.com.au/journals/test/1:1/");
+        article = m.createResource("http://miskinhill.com.au/journals/test/1:1/article");
+        author = m.createResource("http://miskinhill.com.au/authors/test-author");
+        book = m.createResource("http://miskinhill.com.au/cited/books/test");
+        review = m.createResource("http://miskinhill.com.au/journals/test/1:1/reviews/review");
+        anotherReview = m.createResource("http://miskinhill.com.au/journals/test/2:1/reviews/another-review");
+        obituary = m.createResource("http://miskinhill.com.au/journals/test/1:1/in-memoriam-john-doe");
+        en = m.createResource("http://www.lingvoj.org/lang/en");
+        ru = m.createResource("http://www.lingvoj.org/lang/ru");
+    }
+    
+    @Test
+    public void shouldEvaluateTraversal() {
+        RDFNode result = SelectorParser.parse("dc:creator").withResultType(RDFNode.class).singleResult(article);
+        assertThat(result, equalTo((RDFNode) author));
+    }
+    
+    @Test
+    public void shouldEvaluateMultipleTraversals() throws Exception {
+        RDFNode result = SelectorParser.parse("dc:creator/foaf:name")
+                .withResultType(RDFNode.class).singleResult(article);
+        assertThat(((Literal) result).getString(), equalTo("Test Author"));
+    }
+    
+    @Test
+    public void shouldEvaluateInverseTraversal() throws Exception {
+        List<RDFNode> results = SelectorParser.parse("!mhs:isIssueOf/!dc:isPartOf")
+                .withResultType(RDFNode.class).result(journal);
+        assertThat(results.size(), equalTo(4));
+        assertThat(results, hasItems((RDFNode) article, (RDFNode) review, (RDFNode) anotherReview, (RDFNode) obituary));
+    }
+    
+    @Test
+    public void shouldEvaluateSortOrder() throws Exception {
+        List<RDFNode> results = SelectorParser.parse("dc:language(lingvoj:iso1#comparable-lv)")
+                .withResultType(RDFNode.class).result(journal);
+        assertThat(results.size(), equalTo(2));
+        assertThat(results.get(0), equalTo((RDFNode) en));
+        assertThat(results.get(1), equalTo((RDFNode) ru));
+    }
+    
+    @Test
+    public void shouldEvaluateReverseSortOrder() throws Exception {
+        List<RDFNode> results = SelectorParser.parse("dc:language(~lingvoj:iso1#comparable-lv)")
+                .withResultType(RDFNode.class).result(journal);
+        assertThat(results.size(), equalTo(2));
+        assertThat(results.get(0), equalTo((RDFNode) ru));
+        assertThat(results.get(1), equalTo((RDFNode) en));
+    }
+    
+    @Test
+    public void shouldEvaluateComplexSortOrder() throws Exception {
+        List<RDFNode> results = SelectorParser.parse("!mhs:reviews(dc:isPartOf/mhs:publicationDate#comparable-lv)")
+                .withResultType(RDFNode.class).result(book);
+        assertThat(results.size(), equalTo(2));
+        assertThat(results.get(0), equalTo((RDFNode) review));
+        assertThat(results.get(1), equalTo((RDFNode) anotherReview));
+    }
+    
+    @Test
+    public void shouldEvaluateUriAdaptation() throws Exception {
+        String result = SelectorParser.parse("mhs:coverThumbnail#uri")
+                .withResultType(String.class).singleResult(issue);
+        assertThat(result, equalTo("http://miskinhill.com.au/journals/test/1:1/cover.thumb.jpg"));
+    }
+    
+    @Test
+    public void shouldEvaluateBareUriAdaptation() throws Exception {
+        String result = SelectorParser.parse("#uri").withResultType(String.class).singleResult(journal);
+        assertThat(result, equalTo("http://miskinhill.com.au/journals/test/"));
+    }
+    
+    @Test
+    public void shouldEvaluateUriSliceAdaptation() throws Exception {
+        String result = SelectorParser.parse("dc:identifier[uri-prefix='urn:issn:']#uri-slice(9)")
+                .withResultType(String.class).singleResult(journal);
+        assertThat(result, equalTo("12345678"));
+    }
+    
+    @Test
+    public void shouldEvaluateSubscript() throws Exception {
+        String result = SelectorParser.parse(
+                "!mhs:isIssueOf(~mhs:publicationDate#comparable-lv)[0]/mhs:coverThumbnail#uri")
+                .withResultType(String.class).singleResult(journal);
+        assertThat(result, equalTo("http://miskinhill.com.au/journals/test/2:1/cover.thumb.jpg"));
+        result = SelectorParser.parse(
+                "!mhs:isIssueOf(mhs:publicationDate#comparable-lv)[0]/mhs:coverThumbnail#uri")
+                .withResultType(String.class).singleResult(journal);
+        assertThat(result, equalTo("http://miskinhill.com.au/journals/test/1:1/cover.thumb.jpg"));
+    }
+    
+    @Test
+    public void shouldEvaluateLVAdaptation() throws Exception {
+        List<Object> results = SelectorParser.parse("dc:language/lingvoj:iso1#lv")
+                .withResultType(Object.class).result(journal);
+        assertThat(results.size(), equalTo(2));
+        assertThat(results, hasItems((Object) "en", (Object) "ru"));
+    }
+    
+}
diff --git a/src/test/java/au/com/miskinhill/rdftemplate/selector/SelectorMatcher.java b/src/test/java/au/com/miskinhill/rdftemplate/selector/SelectorMatcher.java
@@ -0,0 +1,30 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+
+import static org.junit.matchers.JUnitMatchers.hasItems;
+
+import org.hamcrest.Matcher;
+
+public class SelectorMatcher<T extends Selector<?>> extends BeanPropertyMatcher<T> {
+
+    private SelectorMatcher(Class<? extends T> type) {
+        super(type);
+    }
+    
+    public static SelectorMatcher<Selector<?>> selector(Matcher<Traversal>... traversals) {
+        if (traversals.length == 0) {
+            return new SelectorMatcher<Selector<?>>(NoopSelector.class);
+        }
+        SelectorMatcher<Selector<?>> m = new SelectorMatcher<Selector<?>>(TraversingSelector.class);
+        m.addRequiredProperty("traversals", hasItems(traversals));
+        return m;
+    }
+    
+    public <A> SelectorMatcher<Selector<?>> withAdaptation(Matcher<? extends Adaptation<A>> adaptation) {
+        SelectorMatcher<Selector<?>> m = new SelectorMatcher(SelectorWithAdaptation.class);
+        m.addRequiredProperty("baseSelector", this);
+        m.addRequiredProperty("adaptation", adaptation);
+        return m;
+    }
+    
+}
diff --git a/src/test/java/au/com/miskinhill/rdftemplate/selector/SelectorParserUnitTest.java b/src/test/java/au/com/miskinhill/rdftemplate/selector/SelectorParserUnitTest.java
@@ -0,0 +1,134 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+import static au.com.miskinhill.rdftemplate.selector.AdaptationMatcher.*;
+import static au.com.miskinhill.rdftemplate.selector.PredicateMatcher.*;
+import static au.com.miskinhill.rdftemplate.selector.SelectorMatcher.selector;
+import static au.com.miskinhill.rdftemplate.selector.TraversalMatcher.traversal;
+
+import static org.junit.Assert.assertThat;
+
+import org.junit.Test;
+
+public class SelectorParserUnitTest {
+    
+    @Test
+    public void shouldRecogniseSingleTraversal() throws Exception {
+        Selector<?> selector = SelectorParser.parse("dc:creator");
+        assertThat(selector, selector(traversal("dc", "creator")));
+    }
+    
+    @Test
+    public void shouldRecogniseMultipleTraversals() throws Exception {
+        Selector<?> selector = SelectorParser.parse("dc:creator/foaf:name");
+        assertThat(selector, selector(
+                traversal("dc", "creator"),
+                traversal("foaf", "name")));
+    }
+    
+    @Test
+    public void shouldRecogniseInverseTraversal() throws Exception {
+        Selector<?> selector = SelectorParser.parse("!dc:isPartOf/!dc:isPartOf");
+        assertThat(selector, selector(
+                traversal("dc", "isPartOf").inverse(),
+                traversal("dc", "isPartOf").inverse()));
+    }
+    
+    @Test
+    public void shouldRecogniseSortOrder() throws Exception {
+        Selector<?> selector = SelectorParser.parse("!mhs:isIssueOf(mhs:publicationDate#comparable-lv)");
+        assertThat(selector, selector(
+                traversal("mhs", "isIssueOf").inverse()
+                .withSortOrder(selector(traversal("mhs", "publicationDate"))
+                    .withAdaptation(comparableLVAdaptation()))));
+    }
+    
+    @Test
+    public void shouldRecogniseReverseSortOrder() throws Exception {
+        Selector<?> selector = SelectorParser.parse("!mhs:isIssueOf(~mhs:publicationDate#comparable-lv)");
+        assertThat(selector, selector(
+                traversal("mhs", "isIssueOf").inverse()
+                .withSortOrder(selector(traversal("mhs", "publicationDate"))
+                    .withAdaptation(comparableLVAdaptation()))
+                .reverseSorted()));
+    }
+    
+    @Test
+    public void shouldRecogniseComplexSortOrder() throws Exception {
+        Selector<?> selector = SelectorParser.parse("!mhs:reviews(dc:isPartOf/mhs:publicationDate#comparable-lv)");
+        assertThat(selector, selector(
+                traversal("mhs", "reviews")
+                .withSortOrder(selector(traversal("dc", "isPartOf"), traversal("mhs", "publicationDate"))
+                        .withAdaptation(comparableLVAdaptation()))));
+    }
+    
+    @Test
+    public void shouldRecogniseUriAdaptation() throws Exception {
+        Selector<?> selector = SelectorParser.parse("mhs:coverThumbnail#uri");
+        assertThat(selector, selector(
+                traversal("mhs", "coverThumbnail"))
+                .withAdaptation(uriAdaptation()));
+    }
+    
+    @Test
+    public void shouldRecogniseBareUriAdaptation() throws Exception {
+        Selector<?> selector = SelectorParser.parse("#uri");
+        assertThat(selector, selector().withAdaptation(uriAdaptation()));
+    }
+    
+    @Test
+    public void shouldRecogniseUriSliceAdaptation() throws Exception {
+        Selector<?> selector = SelectorParser.parse("dc:identifier[uri-prefix='urn:issn:']#uri-slice(9)");
+        assertThat(selector, selector(
+                traversal("dc", "identifier")
+                    .withPredicate(uriPrefixPredicate("urn:issn:")))
+                .withAdaptation(uriSliceAdaptation(9)));
+    }
+    
+    @Test
+    public void shouldRecogniseUriPrefixPredicate() throws Exception {
+        Selector<?> selector = SelectorParser.parse(
+                "!mhs:isIssueOf[uri-prefix='http://miskinhill.com.au/journals/'](~mhs:publicationDate#comparable-lv)");
+        assertThat(selector, selector(
+                traversal("mhs", "isIssueOf")
+                    .inverse()
+                    .withPredicate(uriPrefixPredicate("http://miskinhill.com.au/journals/"))
+                    .withSortOrder(selector(traversal("mhs", "publicationDate"))
+                            .withAdaptation(comparableLVAdaptation()))
+                    .reverseSorted()));
+    }
+    
+    @Test
+    public void shouldRecogniseSubscript() throws Exception {
+        Selector<?> selector = SelectorParser.parse(
+                "!mhs:isIssueOf(~mhs:publicationDate#comparable-lv)[0]/mhs:coverThumbnail#uri");
+        assertThat(selector, selector(
+                traversal("mhs", "isIssueOf")
+                    .inverse()
+                    .withSortOrder(selector(traversal("mhs", "publicationDate"))
+                            .withAdaptation(comparableLVAdaptation()))
+                    .reverseSorted()
+                    .withSubscript(0),
+                traversal("mhs", "coverThumbnail"))
+                .withAdaptation(uriAdaptation()));
+    }
+    
+    @Test
+    public void shouldRecogniseLVAdaptation() throws Exception {
+        Selector<?> selector = SelectorParser.parse("dc:language/lingvoj:iso1#lv");
+        assertThat(selector, selector(
+                traversal("dc", "language"),
+                traversal("lingvoj", "iso1"))
+                .withAdaptation(lvAdaptation()));
+    }
+    
+    @Test(expected = InvalidSelectorSyntaxException.class)
+    public void shouldThrowForInvalidSyntax() throws Exception {
+        SelectorParser.parse("dc:creator]["); // this is a parser error
+    }
+    
+    @Test(expected = InvalidSelectorSyntaxException.class)
+    public void shouldThrowForUnrecognisedCharacter() throws Exception {
+        SelectorParser.parse("dc:cre&ator"); // ... and this is a lexer error
+    }
+    
+}
diff --git a/src/test/java/au/com/miskinhill/rdftemplate/selector/TraversalMatcher.java b/src/test/java/au/com/miskinhill/rdftemplate/selector/TraversalMatcher.java
@@ -0,0 +1,46 @@
+package au.com.miskinhill.rdftemplate.selector;
+
+
+import static org.hamcrest.CoreMatchers.equalTo;
+
+import org.hamcrest.Matcher;
+
+public class TraversalMatcher extends BeanPropertyMatcher<Traversal> {
+    
+    private TraversalMatcher() {
+        super(Traversal.class);
+    }
+    
+    public static TraversalMatcher traversal(String propertyNamespacePrefix, String propertyLocalName) {
+        TraversalMatcher m = new TraversalMatcher();
+        m.addRequiredProperty("propertyNamespacePrefix", equalTo(propertyNamespacePrefix));
+        m.addRequiredProperty("propertyLocalName", equalTo(propertyLocalName));
+        return m;
+    }
+    
+    public TraversalMatcher inverse() {
+        addRequiredProperty("inverse", equalTo(true));
+        return this;
+    }
+    
+    public TraversalMatcher withPredicate(Matcher<? extends Predicate> predicate) {
+        addRequiredProperty("predicate", predicate);
+        return this;
+    }
+    
+    public TraversalMatcher withSortOrder(Matcher<Selector<?>> sortOrder) {
+        addRequiredProperty("sortOrder", sortOrder);
+        return this;
+    }
+    
+    public TraversalMatcher reverseSorted() {
+        addRequiredProperty("reverseSorted", equalTo(true));
+        return this;
+    }
+    
+    public TraversalMatcher withSubscript(int subscript) {
+        addRequiredProperty("subscript", equalTo(subscript));
+        return this;
+    }
+
+}
diff --git a/src/test/resources/au/com/miskinhill/rdftemplate/replace-subtree.xml b/src/test/resources/au/com/miskinhill/rdftemplate/replace-subtree.xml
@@ -0,0 +1,10 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:rdf="http://code.miskinhill.com.au/rdftemplate/">
+<body>
+
+<div rdf:content="dc:description">
+    <p>This should all go <em>away</em>!</p>
+</div>
+
+</body>
+</html>
+\ No newline at end of file
diff --git a/src/test/resources/au/com/miskinhill/rdftemplate/test-data.xml b/src/test/resources/au/com/miskinhill/rdftemplate/test-data.xml
@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="utf-8"?>
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+         xmlns:dc="http://purl.org/dc/terms/"
+         xmlns:foaf="http://xmlns.com/foaf/0.1/"
+         xmlns:contact="http://www.w3.org/2000/10/swap/pim/contact#"
+         xmlns:geonames="http://www.geonames.org/ontology#"
+         xmlns:lingvoj="http://www.lingvoj.org/ontology#"
+         xmlns:mhs="http://miskinhill.com.au/rdfschema/1.0/">
+  <mhs:Journal rdf:about="http://miskinhill.com.au/journals/test/">
+    <dc:title xml:lang="en">Test Journal of Good Stuff</dc:title>
+    <dc:publisher rdf:nodeID="pub"/>
+    <dc:identifier rdf:resource="urn:issn:12345678"/>
+    <dc:description xml:lang="en">A journal, you know, with some stuff in it</dc:description>
+    <dc:language rdf:resource="http://www.lingvoj.org/lang/en" />
+    <dc:language rdf:resource="http://www.lingvoj.org/lang/ru" />
+    <mhs:beginningDate>1987</mhs:beginningDate>
+  </mhs:Journal>
+  <lingvoj:Lingvo rdf:about="http://www.lingvoj.org/lang/en">
+    <lingvoj:iso1>en</lingvoj:iso1>
+    <lingvoj:iso2b>eng</lingvoj:iso2b>
+  </lingvoj:Lingvo>
+  <lingvoj:Lingvo rdf:about="http://www.lingvoj.org/lang/ru">
+    <lingvoj:iso1>ru</lingvoj:iso1>
+    <lingvoj:iso2b>rus</lingvoj:iso2b>
+  </lingvoj:Lingvo>
+  <mhs:Publisher rdf:nodeID="pub">
+    <foaf:name xml:lang="en">Awesome Publishing House</foaf:name>
+    <foaf:homepage rdf:resource="http://awesome.com"/>
+    <contact:address rdf:resource="http://sws.geonames.org/2150650/"/>
+  </mhs:Publisher>
+  <geonames:Feature rdf:about="http://sws.geonames.org/2150650/">
+    <geonames:name xml:lang="en">St Lucia, Qld.</geonames:name>
+  </geonames:Feature>
+  <mhs:Issue rdf:about="http://miskinhill.com.au/journals/test/1:1/">
+    <mhs:isIssueOf rdf:resource="http://miskinhill.com.au/journals/test/"/>
+    <mhs:volume rdf:datatype="http://www.w3.org/2001/XMLSchema#integer">1</mhs:volume>
+    <mhs:issueNumber>1</mhs:issueNumber>
+    <dc:coverage rdf:datatype="http://www.w3.org/TR/xmlschema-2/#date">2007</dc:coverage>
+    <mhs:publicationDate rdf:datatype="http://www.w3.org/TR/xmlschema-2/#date">2008-02-01</mhs:publicationDate>
+    <mhs:onlinePublicationDate rdf:datatype="http://www.w3.org/TR/xmlschema-2/#date">2008-03-01</mhs:onlinePublicationDate>
+    <mhs:coverThumbnail rdf:resource="http://miskinhill.com.au/journals/test/1:1/cover.thumb.jpg"/>
+  </mhs:Issue>
+  <mhs:Issue rdf:about="http://miskinhill.com.au/journals/test/2:1/">
+    <mhs:isIssueOf rdf:resource="http://miskinhill.com.au/journals/test/"/>
+    <mhs:volume rdf:datatype="http://www.w3.org/2001/XMLSchema#integer">2</mhs:volume>
+    <mhs:issueNumber>1</mhs:issueNumber>
+    <dc:coverage rdf:datatype="http://www.w3.org/TR/xmlschema-2/#date">2008</dc:coverage>
+    <mhs:publicationDate rdf:datatype="http://www.w3.org/TR/xmlschema-2/#date">2009-02-01</mhs:publicationDate>
+    <mhs:onlinePublicationDate rdf:datatype="http://www.w3.org/TR/xmlschema-2/#date">2009-03-01</mhs:onlinePublicationDate>
+    <mhs:coverThumbnail rdf:resource="http://miskinhill.com.au/journals/test/2:1/cover.thumb.jpg"/>
+  </mhs:Issue>
+  <mhs:Article rdf:about="http://miskinhill.com.au/journals/test/1:1/article">
+    <dc:isPartOf rdf:resource="http://miskinhill.com.au/journals/test/1:1/"/>
+    <dc:creator rdf:resource="http://miskinhill.com.au/authors/test-author"/>
+    <dc:title rdf:parseType="Literal"><span xmlns="http://www.w3.org/1999/xhtml" lang="en"><em>Moscow 1937</em>: the interpreter’s story</span></dc:title>
+    <mhs:startPage rdf:datatype="http://www.w3.org/2001/XMLSchema#integer">5</mhs:startPage>
+    <mhs:endPage rdf:datatype="http://www.w3.org/2001/XMLSchema#integer">35</mhs:endPage>
+  </mhs:Article>
+  <mhs:Author rdf:about="http://miskinhill.com.au/authors/test-author">
+    <foaf:name>Test Author</foaf:name>
+    <foaf:surname>Author</foaf:surname>
+    <foaf:givenNames>Test</foaf:givenNames>
+  </mhs:Author>
+  <mhs:Journal rdf:about="http://miskinhill.com.au/cited/journals/asdf/">
+    <dc:title xml:lang="en">A Cited Journal</dc:title>
+    <dc:identifier rdf:resource="urn:issn:87654321"/>
+  </mhs:Journal>
+  <mhs:Issue rdf:about="http://miskinhill.com.au/cited/journals/asdf/1:1/">
+    <mhs:isIssueOf rdf:resource="http://miskinhill.com.au/cited/journals/asdf/"/>
+    <mhs:volume rdf:datatype="http://www.w3.org/2001/XMLSchema#integer">1</mhs:volume>
+    <mhs:issueNumber>1</mhs:issueNumber>
+  </mhs:Issue>
+  <mhs:Article rdf:about="http://miskinhill.com.au/cited/journals/asdf/1:1/article">
+    <dc:isPartOf rdf:resource="http://miskinhill.com.au/cited/journals/asdf/1:1/"/>
+    <dc:creator rdf:resource="http://miskinhill.com.au/authors/test-author"/>
+    <dc:creator rdf:resource="http://miskinhill.com.au/authors/another-author"/>
+    <dc:title>Boris Pasternak in August 1936: an NKVD memorandum</dc:title>
+    <mhs:availableFrom rdf:resource="http://example.com/teh-cited-article"/>
+  </mhs:Article>
+  <mhs:Author rdf:about="http://miskinhill.com.au/authors/another-author">
+    <foaf:name>Another Author</foaf:name>
+    <foaf:surname>Author</foaf:surname>
+    <foaf:givenNames>Another</foaf:givenNames>
+  </mhs:Author>
+  <mhs:Book rdf:about="http://miskinhill.com.au/cited/books/test">
+    <dc:identifier rdf:resource="urn:isbn:9780415274319"/>
+    <dc:identifier rdf:resource="urn:asin:0415274311"/>
+    <dc:title xml:lang="en">Slovenia: evolving loyalties</dc:title>
+    <dc:creator>John K. Cox</dc:creator>
+    <dc:publisher>Routledge</dc:publisher>
+    <dc:date rdf:datatype="http://www.w3.org/TR/xmlschema-2/#date">2005</dc:date>
+  </mhs:Book>
+  <mhs:Review rdf:about="http://miskinhill.com.au/journals/test/1:1/reviews/review">
+    <dc:isPartOf rdf:resource="http://miskinhill.com.au/journals/test/1:1/"/>
+    <mhs:reviews rdf:resource="http://miskinhill.com.au/cited/books/test"/>
+    <dc:creator rdf:resource="http://miskinhill.com.au/authors/test-author"/>
+  </mhs:Review>
+  <mhs:Review rdf:about="http://miskinhill.com.au/journals/test/2:1/reviews/another-review">
+    <dc:isPartOf rdf:resource="http://miskinhill.com.au/journals/test/2:1/"/>
+    <mhs:reviews rdf:resource="http://miskinhill.com.au/cited/books/test"/>
+    <dc:creator rdf:resource="http://miskinhill.com.au/authors/another-author"/>
+  </mhs:Review>
+  <mhs:Obituary rdf:about="http://miskinhill.com.au/journals/test/1:1/in-memoriam-john-doe">
+    <dc:title xml:lang="en">In memoriam John Doe</dc:title>
+    <dc:isPartOf rdf:resource="http://miskinhill.com.au/journals/test/1:1/"/>
+    <dc:creator rdf:resource="http://miskinhill.com.au/authors/test-author"/>
+    <mhs:obituaryOf rdf:nodeID="person"/>
+  </mhs:Obituary>
+  <foaf:Person rdf:nodeID="person">
+    <foaf:name>John Doe</foaf:name>
+    <mhs:dateOfBirth rdf:datatype="http://www.w3.org/TR/xmlschema-2/#date">1990-01-01</mhs:dateOfBirth>
+    <mhs:dateOfDeath rdf:datatype="http://www.w3.org/TR/xmlschema-2/#date">1991-12-31</mhs:dateOfDeath>
+  </foaf:Person>
+</rdf:RDF>
diff --git a/src/test/resources/au/com/miskinhill/rdftemplate/test-template.out.xml b/src/test/resources/au/com/miskinhill/rdftemplate/test-template.out.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:rdf="http://code.miskinhill.com.au/rdftemplate/" lang="en">
+
+<head>
+    <title xml:lang="en" lang="en">Test Journal of Good Stuff</title>
+</head>
+
+<body>
+
+<h2><em xml:lang="en" lang="en">Test Journal of Good Stuff</em><abbr title="http://miskinhill.com.au/journals/test/" class="unapi-id"></abbr></h2>
+
+<div class="metabox-container">
+    <div class="issue-meta metabox">
+        <h4>Journal details</h4>
+        <p class="cover">
+            <img alt="" src="http://miskinhill.com.au/journals/test/2:1/cover.thumb.jpg"></img>
+        </p>
+        <p class="publisher">Published by <a xml:lang="en" lang="en" href="http://awesome.com">Awesome Publishing House</a></p>
+        <p class="issn">ISSN 12345678</p>
+        <!-- <p>Metadata:
+            ${Markup(u', ').join(r(req, node).anchor() for r in representations.for_node(node) if r.format != 'html')}
+        </p> -->
+    </div>
+</div>
+
+<h3>Issues available online</h3>
+<ul>
+    <li>
+        <a href="http://miskinhill.com.au/journals/test/2:1/">
+            Vol. 2,
+            Issue 1
+            (2008-01-01)
+        </a>
+    </li><li>
+        <a href="http://miskinhill.com.au/journals/test/1:1/">
+            Vol. 1,
+            Issue 1
+            (2007-01-01)
+        </a>
+    </li>
+</ul>
+
+<h3>About this journal</h3>
+<div xml:lang="en" lang="en">A journal, you know, with some stuff in it</div>
+
+</body>
+</html>
+\ No newline at end of file
diff --git a/src/test/resources/au/com/miskinhill/rdftemplate/test-template.xml b/src/test/resources/au/com/miskinhill/rdftemplate/test-template.xml
@@ -0,0 +1,42 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xmlns:rdf="http://code.miskinhill.com.au/rdftemplate/"
+      lang="en">
+
+<head>
+    <title rdf:content="dc:title" />
+</head>
+
+<body>
+
+<h2><em rdf:content="dc:title" /><abbr class="unapi-id" title="${#uri}" /></h2>
+
+<div class="metabox-container">
+    <div class="issue-meta metabox">
+        <h4>Journal details</h4>
+        <p class="cover">
+            <img src="${!mhs:isIssueOf(~mhs:publicationDate#comparable-lv)[0]/mhs:coverThumbnail#uri}" alt="" />
+        </p>
+        <p class="publisher">Published by <a href="${dc:publisher/foaf:homepage#uri}" rdf:content="dc:publisher/foaf:name" /></p>
+        <p class="issn">ISSN ${dc:identifier[uri-prefix='urn:issn:']#uri-slice(9)}</p>
+        <!-- <p>Metadata:
+            ${Markup(u', ').join(r(req, node).anchor() for r in representations.for_node(node) if r.format != 'html')}
+        </p> -->
+    </div>
+</div>
+
+<h3>Issues available online</h3>
+<ul>
+    <li rdf:for="!mhs:isIssueOf[uri-prefix='http://miskinhill.com.au/journals/'](~mhs:publicationDate#comparable-lv)">
+        <a href="${#uri}">
+            Vol.&#160;${mhs:volume},
+            Issue&#160;${mhs:issueNumber}
+            (${dc:coverage})
+        </a>
+    </li>
+</ul>
+
+<h3>About this journal</h3>
+<div rdf:content="dc:description" />
+
+</body>
+</html>
+\ No newline at end of file