diff --git a/pom.xml b/pom.xml
index ba4aa4edb..2494ffd04 100644
--- a/pom.xml
+++ b/pom.xml
@@ -426,6 +426,12 @@
${thymeleaf.extras.version}
true
+
+ de.neuland-bfi
+ jade4j
+ 0.4.2
+ true
+
org.slf4j
diff --git a/src/main/assembly/assembly-example-project-jade.xml b/src/main/assembly/assembly-example-project-jade.xml
new file mode 100644
index 000000000..71cc39a23
--- /dev/null
+++ b/src/main/assembly/assembly-example-project-jade.xml
@@ -0,0 +1,19 @@
+
+ base
+ false
+
+
+ zip
+
+
+
+ ${project.build.directory}/example-project-jade/
+ /
+
+ .git
+ README.md
+ LICENSE
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/org/jbake/template/JadeTemplateEngine.java b/src/main/java/org/jbake/template/JadeTemplateEngine.java
new file mode 100644
index 000000000..31ecb848b
--- /dev/null
+++ b/src/main/java/org/jbake/template/JadeTemplateEngine.java
@@ -0,0 +1,155 @@
+package org.jbake.template;
+
+import com.orientechnologies.orient.core.db.document.ODatabaseDocumentTx;
+import com.orientechnologies.orient.core.record.impl.ODocument;
+import com.orientechnologies.orient.core.sql.query.OSQLSynchQuery;
+
+import de.neuland.jade4j.JadeConfiguration;
+import de.neuland.jade4j.exceptions.JadeCompilerException;
+import de.neuland.jade4j.filter.CDATAFilter;
+import de.neuland.jade4j.filter.CssFilter;
+import de.neuland.jade4j.filter.Filter;
+import de.neuland.jade4j.filter.JsFilter;
+import de.neuland.jade4j.model.JadeModel;
+import de.neuland.jade4j.template.FileTemplateLoader;
+import de.neuland.jade4j.template.JadeTemplate;
+import de.neuland.jade4j.template.TemplateLoader;
+
+import org.apache.commons.configuration.CompositeConfiguration;
+import org.apache.commons.lang.StringEscapeUtils;
+import org.jbake.app.ContentStore;
+import org.jbake.app.DBUtil;
+import org.jbake.app.DocumentList;
+import org.jbake.model.DocumentTypes;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.Writer;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+/**
+ * Renders pages using the Jade template language.
+ *
+ * @author Aleksandar Vidakovic
+ */
+public class JadeTemplateEngine extends AbstractTemplateEngine {
+ private static final String FILTER_CDATA = "cdata";
+ private static final String FILTER_STYLE = "css";
+ private static final String FILTER_SCRIPT = "js";
+
+ private JadeConfiguration jadeConfiguration = new JadeConfiguration();
+
+ public JadeTemplateEngine(final CompositeConfiguration config, final ContentStore db, final File destination, final File templatesPath) {
+ super(config, db, destination, templatesPath);
+
+ TemplateLoader loader = new FileTemplateLoader(templatesPath.getAbsolutePath() + "/", "UTF-8");
+ jadeConfiguration.setTemplateLoader(loader);
+ jadeConfiguration.setFilter(FILTER_CDATA, new CDATAFilter());
+ jadeConfiguration.setFilter(FILTER_SCRIPT, new JsFilter());
+ jadeConfiguration.setFilter(FILTER_STYLE, new CssFilter());
+ jadeConfiguration.getSharedVariables().put("formatter", new FormatHelper());
+ }
+
+ @Override
+ public void renderDocument(Map model, String templateName, Writer writer) throws RenderingException {
+ try {
+ JadeTemplate template = jadeConfiguration.getTemplate(templateName);
+
+ renderTemplate(template, model, writer);
+ } catch (IOException e) {
+ throw new RenderingException(e);
+ }
+ }
+
+ public void renderTemplate(JadeTemplate template, Map model, Writer writer) throws JadeCompilerException {
+ JadeModel jadeModel = wrap(jadeConfiguration.getSharedVariables());
+ jadeModel.putAll(model);
+ template.process(jadeModel, writer);
+ }
+
+ private JadeModel wrap(final Map model) {
+ return new JadeModel(model) {
+
+ @Override
+ public Object get(final Object property) {
+ String key = property.toString();
+ if ("db".equals(key)) {
+ return db;
+ }
+ if ("published_posts".equals(key)) {
+ List query = db.getPublishedPosts();
+ return DocumentList.wrap(query.iterator());
+ }
+ if ("published_pages".equals(key)) {
+ List query = db.getPublishedPages();
+ return DocumentList.wrap(query.iterator());
+ }
+ if ("published_content".equals(key)) {
+ List publishedContent = new ArrayList();
+ String[] documentTypes = DocumentTypes.getDocumentTypes();
+ for (String docType : documentTypes) {
+ List query = db.getPublishedContent(docType);
+ publishedContent.addAll(query);
+ }
+ return DocumentList.wrap(publishedContent.iterator());
+ }
+ if ("all_content".equals(key)) {
+ List allContent = new ArrayList();
+ String[] documentTypes = DocumentTypes.getDocumentTypes();
+ for (String docType : documentTypes) {
+ List query = db.getAllContent(docType);
+ allContent.addAll(query);
+ }
+ return DocumentList.wrap(allContent.iterator());
+ }
+ if ("alltags".equals(key)) {
+ List query = db.getAllTagsFromPublishedPosts();
+ Set result = new HashSet();
+ for (ODocument document : query) {
+ String[] tags = DBUtil.toStringArray(document.field("tags"));
+ Collections.addAll(result, tags);
+ }
+ return result;
+ }
+ String[] documentTypes = DocumentTypes.getDocumentTypes();
+ for (String docType : documentTypes) {
+ if ((docType+"s").equals(key)) {
+ return DocumentList.wrap(db.getAllContent(docType).iterator());
+ }
+ }
+ if ("tag_posts".equals(key)) {
+ String tag = model.get("tag").toString();
+ // fetch the tag posts from db
+ List query = db.getPublishedPostsByTag(tag);
+ return DocumentList.wrap(query.iterator());
+ }
+
+ return super.get(property);
+ }
+ };
+ }
+
+ public static class FormatHelper {
+ private Map formatters = new HashMap();
+
+ public String format(Date date, String pattern) {
+ if(date!=null && pattern!=null) {
+ SimpleDateFormat df = formatters.get(pattern);
+
+ if(df==null) {
+ df = new SimpleDateFormat(pattern);
+ formatters.put(pattern, df);
+ }
+
+ return df.format(date);
+ } else {
+ return "";
+ }
+ }
+
+ public String escape(String s) {
+ return StringEscapeUtils.escapeHtml(s);
+ }
+ }
+}
diff --git a/src/main/resources/META-INF/org.jbake.parser.TemplateEngines.properties b/src/main/resources/META-INF/org.jbake.parser.TemplateEngines.properties
index ba78df94c..934245673 100644
--- a/src/main/resources/META-INF/org.jbake.parser.TemplateEngines.properties
+++ b/src/main/resources/META-INF/org.jbake.parser.TemplateEngines.properties
@@ -2,4 +2,6 @@ org.jbake.template.FreemarkerTemplateEngine=ftl
org.jbake.template.GroovyTemplateEngine=groovy,gsp,gxml
org.jbake.template.GroovyMarkupTemplateEngine=tpl
org.jbake.template.ThymeleafTemplateEngine=thyme
-org.jbake.template.PebbleTemplateEngine=pebble,peb
\ No newline at end of file
+org.jbake.template.PebbleTemplateEngine=pebble,peb
+org.jbake.template.ThymeleafTemplateEngine=thyme,html
+org.jbake.template.JadeTemplateEngine=jade
diff --git a/src/test/java/org/jbake/app/JadeRendererTest.java b/src/test/java/org/jbake/app/JadeRendererTest.java
new file mode 100644
index 000000000..40a1de0e3
--- /dev/null
+++ b/src/test/java/org/jbake/app/JadeRendererTest.java
@@ -0,0 +1,208 @@
+package org.jbake.app;
+
+import com.orientechnologies.orient.core.db.document.ODatabaseDocumentTx;
+import org.apache.commons.configuration.CompositeConfiguration;
+import org.apache.commons.io.FileUtils;
+import org.jbake.app.ConfigUtil.Keys;
+import org.jbake.model.DocumentTypes;
+import org.junit.*;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.Iterator;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class JadeRendererTest {
+
+ @Rule
+ public TemporaryFolder folder = new TemporaryFolder();
+
+ private File sourceFolder;
+ private File destinationFolder;
+ private File templateFolder;
+ private CompositeConfiguration config;
+ private ContentStore db;
+
+ @Before
+ public void setup() throws Exception, IOException, URISyntaxException {
+ URL sourceUrl = this.getClass().getResource("/");
+
+ sourceFolder = new File(sourceUrl.getFile());
+ if (!sourceFolder.exists()) {
+ throw new Exception("Cannot find sample data structure!");
+ }
+
+ destinationFolder = folder.getRoot();
+
+ templateFolder = new File(sourceFolder, "jadeTemplates");
+ if (!templateFolder.exists()) {
+ throw new Exception("Cannot find template folder!");
+ }
+
+ config = ConfigUtil.load(new File(this.getClass().getResource("/").getFile()));
+ Iterator keys = config.getKeys();
+ while (keys.hasNext()) {
+ String key = keys.next();
+ if (key.startsWith("template") && key.endsWith(".file")) {
+ String old = (String)config.getProperty(key);
+ config.setProperty(key, old.substring(0, old.length()-4)+".jade");
+ }
+ }
+ Assert.assertEquals(".html", config.getString(Keys.OUTPUT_EXTENSION));
+ db = DBUtil.createDataStore("memory", "documents"+System.currentTimeMillis());
+ }
+
+ @After
+ public void cleanup() throws InterruptedException {
+ db.drop();
+ db.close();
+ }
+
+ @Test
+ public void renderPost() throws Exception {
+ // setup
+ Crawler crawler = new Crawler(db, sourceFolder, config);
+ crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content"));
+ Parser parser = new Parser(config, sourceFolder.getPath());
+ Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config);
+ String filename = "second-post.html";
+
+ File sampleFile = new File(sourceFolder.getPath() + File.separator + "content" + File.separator + "blog" + File.separator + "2013" + File.separator + filename);
+ Map content = parser.processFile(sampleFile);
+ content.put("uri", "/" + filename);
+ renderer.render(content);
+ File outputFile = new File(destinationFolder, filename);
+ Assert.assertTrue(outputFile.exists());
+
+ // verify
+ String output = FileUtils.readFileToString(outputFile);
+ assertThat(output)
+ .contains("Second Post
")
+ .contains("28")
+ .contains("2013
")
+ .contains("Lorem ipsum dolor sit amet")
+ .contains("Published Posts
")
+ .contains("blog/2012/first-post.html");
+ }
+
+ @Test
+ public void renderPage() throws Exception {
+ // setup
+ Crawler crawler = new Crawler(db, sourceFolder, config);
+ crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content"));
+ Parser parser = new Parser(config, sourceFolder.getPath());
+ Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config);
+ // TODO: fix this
+ String filename = "about.html";
+
+ File sampleFile = new File(sourceFolder.getPath() + File.separator + "content" + File.separator + filename);
+ Map content = parser.processFile(sampleFile);
+ content.put("uri", "/" + filename);
+ renderer.render(content);
+ File outputFile = new File(destinationFolder, filename);
+ Assert.assertTrue(outputFile.exists());
+
+ // verify
+ String output = FileUtils.readFileToString(outputFile);
+ assertThat(output)
+ .contains("About
")
+ .contains("All about stuff!")
+ .contains("Published Pages
")
+ .contains("/projects.html");
+ }
+
+ @Test
+ public void renderIndex() throws Exception {
+ //setup
+ Crawler crawler = new Crawler(db, sourceFolder, config);
+ crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content"));
+ Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config);
+ //exec
+ renderer.renderIndex("index.html");
+
+ //validate
+ File outputFile = new File(destinationFolder, "index.html");
+ Assert.assertTrue(outputFile.exists());
+
+ // verify
+ String output = FileUtils.readFileToString(outputFile);
+ assertThat(output)
+ .contains("")
+ .contains("");
+ }
+
+ @Test
+ public void renderFeed() throws Exception {
+ Crawler crawler = new Crawler(db, sourceFolder, config);
+ crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content"));
+ Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config);
+ renderer.renderFeed("feed.xml");
+ File outputFile = new File(destinationFolder, "feed.xml");
+ Assert.assertTrue(outputFile.exists());
+
+ // verify
+ String output = FileUtils.readFileToString(outputFile);
+ assertThat(output)
+ .contains("My corner of the Internet")
+ .contains("Second Post")
+ .contains("First Post");
+ }
+
+ @Test
+ public void renderArchive() throws Exception {
+ Crawler crawler = new Crawler(db, sourceFolder, config);
+ crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content"));
+ Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config);
+ renderer.renderArchive("archive.html");
+ File outputFile = new File(destinationFolder, "archive.html");
+ Assert.assertTrue(outputFile.exists());
+
+ // verify
+ String output = FileUtils.readFileToString(outputFile);
+ assertThat(output)
+ .contains("Second Post")
+ .contains("First Post");
+ }
+
+ @Test
+ public void renderTags() throws Exception {
+ Crawler crawler = new Crawler(db, sourceFolder, config);
+ crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content"));
+ Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config);
+ renderer.renderTags(crawler.getTags(), "tags");
+
+ // verify
+ File outputFile = new File(destinationFolder + File.separator + "tags" + File.separator + "blog.html");
+ Assert.assertTrue(outputFile.exists());
+ String output = FileUtils.readFileToString(outputFile);
+ assertThat(output)
+ .contains("Second Post")
+ .contains("First Post");
+ }
+
+ @Test
+ public void renderSitemap() throws Exception {
+ DocumentTypes.addDocumentType("paper");
+ DBUtil.updateSchema(db);
+
+ Crawler crawler = new Crawler(db, sourceFolder, config);
+ crawler.crawl(new File(sourceFolder.getPath() + File.separator + "content"));
+ Renderer renderer = new Renderer(db, destinationFolder, templateFolder, config);
+ renderer.renderSitemap("sitemap.xml");
+ File outputFile = new File(destinationFolder, "sitemap.xml");
+ Assert.assertTrue(outputFile.exists());
+
+ // verify
+ String output = FileUtils.readFileToString(outputFile);
+ assertThat(output)
+ .contains("blog/2013/second-post.html")
+ .contains("blog/2012/first-post.html")
+ .contains("papers/published-paper.html")
+ .doesNotContain("draft-paper.html");
+ }
+}
diff --git a/src/test/resources/jadeTemplates/archive.jade b/src/test/resources/jadeTemplates/archive.jade
new file mode 100644
index 000000000..95c404797
--- /dev/null
+++ b/src/test/resources/jadeTemplates/archive.jade
@@ -0,0 +1,15 @@
+extends layout.jade
+
+block content
+ .row-fluid.marketing
+ .span12
+ h2 Archive
+ each post, index in posts
+ if last_month != formatter.format(post.date, 'MMMM yyyy')
+ h3 #{formatter.format(post.date, 'MMMM yyyy')}
+ h4
+ | #{formatter.format(post.date, 'dd MMMM')}
+ | -
+ a(href='#{post.uri}') #{post.title}
+ - var last_month = formatter.format(post.date, 'MMMM yyyy')
+ hr
diff --git a/src/test/resources/jadeTemplates/feed.jade b/src/test/resources/jadeTemplates/feed.jade
new file mode 100644
index 000000000..125a1ab00
--- /dev/null
+++ b/src/test/resources/jadeTemplates/feed.jade
@@ -0,0 +1,17 @@
+rss(version='2.0', xmlns:atom='http://www.w3.org/2005/Atom')
+ channel
+ title JonathanBullock.com
+ link http://jonathanbullock.com/
+ atom:link(href='http://jonathanbullock.com/feed.xml', rel='self', type='application/rss+xml')
+ description My corner of the Internet
+ language en-gb
+ pubdate #{formatter.format(published_date, 'EEE, d MMM yyyy HH:mm:ss Z')}
+ lastbuilddate #{formatter.format(published_date, 'EEE, d MMM yyyy HH:mm:ss Z')}
+ for post in posts
+ item
+ title #{post.title}
+ link http://jonathanbullock.com#{post.uri}
+ pubdate #{formatter.format(post.date, 'EEE, d MMM yyyy HH:mm:ss Z')}
+ guid(ispermalink='false') #{post.uri}
+ description
+ | #{formatter.escape(post.body)}
diff --git a/src/test/resources/jadeTemplates/footer.jade b/src/test/resources/jadeTemplates/footer.jade
new file mode 100644
index 000000000..44c3e1cea
--- /dev/null
+++ b/src/test/resources/jadeTemplates/footer.jade
@@ -0,0 +1,11 @@
+.footer
+ p
+ | © Jonathan Bullock 2013 | Mixed with
+ a(href='http://twitter.github.com/bootstrap/') Bootstrap v2.3.1
+ | | Baked with
+ a(href='http://jbake.org') JBake #{version}
+// Le javascript
+// ==================================================
+// Placed at the end of the document so the pages load faster
+script(src='/js/jquery-1.9.1.min.js')
+script(src='/js/bootstrap.min.js')
diff --git a/src/test/resources/jadeTemplates/header.jade b/src/test/resources/jadeTemplates/header.jade
new file mode 100644
index 000000000..98aa8ba5c
--- /dev/null
+++ b/src/test/resources/jadeTemplates/header.jade
@@ -0,0 +1,59 @@
+head
+ meta(charset='utf-8')
+ title Jonathan Bullock
+ meta(name='viewport', content='width=device-width, initial-scale=1.0')
+ meta(name='description', content='')
+ meta(name='author', content='Jonathan Bullock')
+ // Le styles
+ link(href='/css/bootstrap.min.css', rel='stylesheet')
+ style(type='text/css').
+ body {
+ padding-top: 20px;
+ padding-bottom: 40px;
+ }
+
+ /* Custom container */
+ .container-narrow {
+ margin: 0 auto;
+ max-width: 700px;
+ }
+
+ .container-narrow > hr {
+ margin: 30px 0;
+ }
+
+ /* Main marketing message and sign up button */
+ .jumbotron {
+ margin: 60px 0;
+ text-align: center;
+ }
+
+ .jumbotron h1 {
+ font-size: 72px;
+ line-height: 1;
+ }
+
+ .jumbotron .btn {
+ font-size: 21px;
+ padding: 14px 24px;
+ }
+
+ /* Supporting marketing content */
+ .marketing {
+ margin: 60px 0;
+ }
+
+ .marketing p + h4 {
+ margin-top: 28px;
+ }
+ link(href='/css/bootstrap-responsive.min.css', rel='stylesheet')
+ // HTML5 shim, for IE6-8 support of HTML5 elements
+ //if lt IE 9
+ script(src='/js/html5shiv.js')
+ // Fav and touch icons
+ //
+
+
+
+
+
diff --git a/src/test/resources/jadeTemplates/index.jade b/src/test/resources/jadeTemplates/index.jade
new file mode 100644
index 000000000..7760b2d38
--- /dev/null
+++ b/src/test/resources/jadeTemplates/index.jade
@@ -0,0 +1,13 @@
+extends layout.jade
+
+block content
+ .row-fluid.marketing
+ .span12
+ each post, index in posts
+ if index < 3
+ h4
+ a(href='#{post.uri}') #{post.title}
+ p #{formatter.format(post.date, 'dd MMMM yyyy')} - #{post.body.substring(0, 150)}...
+
+ a(href='/archive.html') Archive
+ hr
diff --git a/src/test/resources/jadeTemplates/layout.jade b/src/test/resources/jadeTemplates/layout.jade
new file mode 100644
index 000000000..2e9942367
--- /dev/null
+++ b/src/test/resources/jadeTemplates/layout.jade
@@ -0,0 +1,20 @@
+html
+ include header
+ body
+ .container-narrow
+ .masthead
+ ul.nav.nav-pills.pull-right
+ li
+ a(href='/') Home
+ li
+ a(href='/about.html') About
+ li
+ a(href='/projects.html') Projects
+ li
+ a(href='/feed.xml') Subscribe
+ h3.muted Jonathan Bullock
+ hr
+
+ block content
+
+ include footer
\ No newline at end of file
diff --git a/src/test/resources/jadeTemplates/page.jade b/src/test/resources/jadeTemplates/page.jade
new file mode 100644
index 000000000..6a7e93ed6
--- /dev/null
+++ b/src/test/resources/jadeTemplates/page.jade
@@ -0,0 +1,12 @@
+extends layout.jade
+
+block content
+ .row-fluid.marketing
+ .span12
+ h4 #{content.title}
+ p #{content.body}
+ hr
+ h5 Published Pages
+
+ for page in published_pages
+ a(href="#{config.site_host}/#{page.uri}") #{page.title}
diff --git a/src/test/resources/jadeTemplates/post.jade b/src/test/resources/jadeTemplates/post.jade
new file mode 100644
index 000000000..ceba3d5d0
--- /dev/null
+++ b/src/test/resources/jadeTemplates/post.jade
@@ -0,0 +1,13 @@
+extends layout.jade
+
+block content
+ .row-fluid.marketing
+ .span12
+ h2 #{content.title}
+ p.post-date #{formatter.format(content.date,'dd MMMM yyyy')}
+ p #{content.body}
+ hr
+ h5 Published Posts
+
+ for post in published_posts
+ a(href="#{config.site_host}/#{post.uri}") #{post.title}
diff --git a/src/test/resources/jadeTemplates/sitemap.jade b/src/test/resources/jadeTemplates/sitemap.jade
new file mode 100644
index 000000000..c7a178ec1
--- /dev/null
+++ b/src/test/resources/jadeTemplates/sitemap.jade
@@ -0,0 +1,5 @@
+urlset(xmlns='http://www.sitemaps.org/schemas/sitemap/0.9', xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance', xsi:schemalocation='http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd')
+ for content in published_content
+ url
+ loc #{config.site_host}#{content.uri}
+ lastmod #{formatter.format(content.date, 'yyyy-MM-dd')}
diff --git a/src/test/resources/jadeTemplates/tags.jade b/src/test/resources/jadeTemplates/tags.jade
new file mode 100644
index 000000000..d5b257245
--- /dev/null
+++ b/src/test/resources/jadeTemplates/tags.jade
@@ -0,0 +1,15 @@
+extends layout.jade
+
+block content
+ .row-fluid.marketing
+ .span12
+ h2 Tags
+ each post, index in posts
+ if last_month != formatter.format(post.date, 'MMMM yyyy')
+ h3 #{formatter.format(post.date, 'MMMM yyyy')}
+ h4
+ | #{formatter.format(post.date, 'dd MMMM')}
+ | -
+ a(href='#{post.uri}') #{post.title}
+ - var last_month = formatter.format(post.date, 'MMMM yyyy')
+ hr