Commits

Stephen McKamey committed 4a07440

rebuilt static site builder as reusable tool

Comments (0)

Files changed (11)

 duel-js/target
 duel-js/src/test/resources/ga.js
 duel-maven-plugin/target
+duel-staticapps/target
 target
 TODO.txt
 
 cd duel-maven-plugin
 mvn clean deploy -U -DperformRelease=true -Dgpg.keyname=EE82F9AB
+cd ..
+
+cd duel-staticapps
+mvn clean deploy -U -DperformRelease=true -Dgpg.keyname=EE82F9AB

duel-staticapps/example-config.json

+{
+	"targetDir": "target/",
+	"sourceDir": "foo-web/target/foo-web/",
+	"serverPrefix": "com.example.web.views",
+	"cdnMap": "cdn",
+	"cdnLinksMap": "cdnLink",
+	"cdnHost": null,
+	"isDevMode": false,
+	"views": {
+		"index.html":
+			{
+				"view": "HomePage",
+				"data": {
+					"foo": "bar",
+					"blah": "yada"
+				},
+				"extras": null
+			},
+		"error.html":
+			{
+				"view": "ErrorPage",
+				"data": {
+					"foo": "bar",
+					"blah": "yada"
+				},
+				"extras": {
+					"App.foo": "bar",
+					"App.blah": "yada"
+				}
+			}
+	},
+	"files": [
+		"robots.txt",
+		"favicon.ico"
+	]
+}

duel-staticapps/pom.xml

+<?xml version="1.0"?>
+<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/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<parent>
+		<groupId>org.sonatype.oss</groupId>
+		<artifactId>oss-parent</artifactId>
+		<version>7</version>
+		<relativePath></relativePath>
+	</parent>
+
+	<groupId>org.duelengine</groupId>
+	<artifactId>duel-staticapps</artifactId>
+	<version>0.8.1</version>
+	<packaging>jar</packaging>
+
+	<name>DUEL Static Apps</name>
+	<description>DUEL Static Site Generator</description>
+	<url>http://duelengine.org</url>
+	<licenses>
+		<license>
+			<name>MIT License</name>
+			<url>http://duelengine.org/LICENSE.txt</url>
+		</license>
+	</licenses>
+	<scm>
+		<url>https://bitbucket.org/mckamey/duel</url>
+		<connection>scm:hg:https://bitbucket.org/mckamey/duel</connection>
+		<developerConnection>scm:hg:https://bitbucket.org/mckamey/duel</developerConnection>
+	</scm>
+	<developers>
+		<developer>
+			<id>mckamey</id>
+			<name>Stephen M. McKamey</name>
+			<url>http://mck.me</url>
+		</developer>
+	</developers>
+
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+
+		<jackson.version>1.9.4</jackson.version>
+		<codec.version>1.6</codec.version>
+		<slf4j.version>1.6.4</slf4j.version>
+		<junit.version>4.9</junit.version>
+		<jvm.version>1.6</jvm.version>
+	</properties>
+
+	<dependencies>
+		<!-- DUEL runtime -->
+		<dependency>
+			<groupId>org.duelengine</groupId>
+			<artifactId>duel-runtime</artifactId>
+			<version>${project.version}</version>
+		</dependency>
+
+		<!-- Jackson JSON runtime -->
+		<dependency>
+			<groupId>org.codehaus.jackson</groupId>
+			<artifactId>jackson-core-asl</artifactId>
+			<version>${jackson.version}</version>
+		</dependency>
+		<dependency>
+			<groupId>org.codehaus.jackson</groupId>
+			<artifactId>jackson-mapper-asl</artifactId>
+			<version>${jackson.version}</version>
+		</dependency>
+
+		<!-- Base64 library -->
+		<dependency>
+			<groupId>commons-codec</groupId>
+			<artifactId>commons-codec</artifactId>
+			<version>${codec.version}</version>
+		</dependency>
+
+		<!-- Logging library -->
+		<dependency>
+			<groupId>org.slf4j</groupId>
+			<artifactId>slf4j-api</artifactId>
+			<version>${slf4j.version}</version>
+		</dependency>
+		<dependency>
+			<groupId>org.slf4j</groupId>
+			<artifactId>slf4j-jdk14</artifactId>
+			<version>${slf4j.version}</version>
+			<scope>test</scope>
+		</dependency>
+
+		<!-- Unit test library -->
+		<dependency>
+			<groupId>junit</groupId>
+			<artifactId>junit</artifactId>
+			<version>${junit.version}</version>
+			<scope>test</scope>
+		</dependency>
+	</dependencies>
+
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-jar-plugin</artifactId>
+				<version>2.4</version>
+				<configuration>
+					<archive>
+						<manifest>
+							<addClasspath>true</addClasspath>
+							<mainClass>org.duelengine.duel.staticapp.CLI</mainClass>
+						</manifest>
+					</archive>
+				</configuration>
+			</plugin>
+		</plugins>
+		<pluginManagement>
+			<plugins>
+				<plugin>
+					<artifactId>maven-compiler-plugin</artifactId>
+					<version>2.3.2</version>
+					<configuration>
+						<source>${jvm.version}</source>
+						<target>${jvm.version}</target>
+					</configuration>
+				</plugin>
+				<plugin>
+					<artifactId>maven-surefire-plugin</artifactId>
+					<version>2.7.2</version>
+				</plugin>
+			</plugins>
+		</pluginManagement>
+	</build>
+
+	<profiles>
+		<profile>
+			<id>release-sign-artifacts</id>
+			<activation>
+				<property>
+					<name>performRelease</name>
+					<value>true</value>
+				</property>
+			</activation>
+			<build>
+				<plugins>
+					<plugin>
+						<groupId>org.apache.maven.plugins</groupId>
+						<artifactId>maven-gpg-plugin</artifactId>
+						<version>1.4</version>
+						<executions>
+							<execution>
+								<id>sign-artifacts</id>
+								<phase>verify</phase>
+								<goals>
+									<goal>sign</goal>
+								</goals>
+							</execution>
+						</executions>
+					</plugin>
+				</plugins>
+			</build>
+		</profile>
+	</profiles>
+</project>

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/CLI.java

+package org.duelengine.duel.staticapps;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+
+import org.codehaus.jackson.map.ObjectMapper;
+
+public class CLI {
+
+	private static final String HELP = "java -jar duel-static-apps.jar <config>\n"+
+			"  <config>: path to configuration file\n";
+
+	public static void main(String[] args) {
+		if (args.length < 1) {
+			System.out.println(HELP);
+			return;
+		}
+
+		File configFile = new File(args[0]);
+		
+		try {
+			if (!configFile.isFile()) {
+				throw new FileNotFoundException(configFile.getPath());
+			}
+
+			// read config
+			SiteConfig config = new ObjectMapper().reader(SiteConfig.class).readValue(configFile);
+
+			// build site defined by config
+			new SiteBuilder().build(config);
+
+		} catch (Exception ex) {
+			ex.printStackTrace(System.err);
+		}
+	}
+}

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/SiteBuilder.java

+package org.duelengine.duel.staticapps;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.Locale;
+import java.util.Map;
+import java.util.ResourceBundle;
+
+import org.duelengine.duel.DuelContext;
+import org.duelengine.duel.DuelView;
+import org.duelengine.duel.FormatPrefs;
+import org.duelengine.duel.utils.FileUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SiteBuilder {
+
+	private static final Logger log = LoggerFactory.getLogger(SiteBuilder.class);
+	private static final int BUFFER_SIZE = 64*1024;//64K
+	private final byte[] buffer = new byte[BUFFER_SIZE];
+
+	public void build(SiteConfig config)
+			throws FileNotFoundException {
+
+		if (config == null) {
+			throw new NullPointerException("config");
+		}
+
+		File sourceDir = config.sourceDirFile();
+		File targetDir = config.targetDirFile();
+
+		if (sourceDir == null) {
+			throw new NullPointerException("sourceDir");
+		}
+		if (targetDir == null) {
+			throw new NullPointerException("targetDir");
+		}
+		if (!sourceDir.exists()) {
+			throw new FileNotFoundException(sourceDir.getPath());
+		}
+
+		log.info("webapp source: "+sourceDir);
+		log.info("static target: "+targetDir);
+
+		File cdnDir = new File(targetDir, "cdn");
+		if (cdnDir.isDirectory() && cdnDir.exists()) {
+			log.info("Emptying existing CDN dir: "+cdnDir.getAbsolutePath());
+			for (File child : cdnDir.listFiles()) {
+				try {
+					if (child.delete()) {
+						log.trace("Deleting existing: "+child.getAbsolutePath());
+					}
+				} catch (Exception ex) {
+					log.warn(ex.getMessage(), ex);
+				}
+			}
+		}
+
+		// link transformer which caches list of URLs
+		StaticLinkInterceptor linkInterceptor = null;
+		try {
+			String bundleName = config.cdnMap();
+			ResourceBundle cdnBundle =
+				(bundleName == null) || bundleName.isEmpty() ? null :
+				ResourceBundle.getBundle(bundleName, Locale.ROOT);
+
+			bundleName = config.cdnLinksMap();
+			ResourceBundle cdnLinkBundle =
+				(bundleName == null) || bundleName.isEmpty() ? null :
+				ResourceBundle.getBundle(bundleName, Locale.ROOT);
+
+			linkInterceptor = new StaticLinkInterceptor(config.cdnHost(), cdnBundle, cdnLinkBundle, config.isDevMode());
+
+		} catch (URISyntaxException ex) {
+			log.error(ex.getMessage(), ex);
+		}
+
+		FormatPrefs formatPrefs = new FormatPrefs()
+			.setEncoding("UTF-8")
+			.setIndent("")
+			.setNewline("");
+
+		Map<String, SiteViewPage> views = config.views();
+		if (views != null) {
+			for (String targetPage : views.keySet()) {
+				SiteViewPage view = views.get(targetPage);
+				log.info("source view: "+view.view());
+				log.info("target page: "+targetPage);
+
+				FileWriter writer = null;
+				try {
+				File indexFile = new File(targetDir, targetPage);
+				FileUtil.prepSavePath(indexFile);
+
+				writer = new FileWriter(indexFile);
+
+				DuelContext context = new DuelContext()
+					.setFormat(formatPrefs)
+					.setLinkInterceptor(linkInterceptor)
+					.setData(view.data())
+					.setOutput(writer);
+
+				Map<String, Object> extras = view.extras();
+				if (extras != null && !extras.isEmpty()) {
+					// ambient client-side data
+					context.putExtras(extras);
+				}
+
+				log.trace("Generating: "+targetPage);
+				viewClass(config.serverPrefix(), view.view()).newInstance().render(context);
+
+				} catch (Exception ex) {
+					log.error(ex.getMessage(), ex);
+
+				} finally {
+					if (writer != null) {
+						try {
+							writer.flush();
+							writer.close();
+						} catch (IOException ex) {}
+					}
+				}
+			}
+		}
+
+		// copy static resources which are blindly requested by userAgents (e.g., "robots.txt", "favicon.ico")
+		String[] staticFiles = config.files();
+		if (staticFiles != null) {
+			for (String staticFile : staticFiles) {
+				try {
+					copyResource(sourceDir, targetDir, staticFile, staticFile);
+				} catch (IOException ex) {
+					log.error(ex.getMessage(), ex);
+				}
+			}
+		}
+
+		Map<String, String> linkCache = linkInterceptor.getLinkCache();
+		for (String key : linkCache.keySet()) {
+			try {
+				copyResource(sourceDir, targetDir, key, linkCache.get(key));
+			} catch (IOException ex) {
+				log.error(ex.getMessage(), ex);
+			}
+		}
+	}
+
+	private void copyResource(File sourceDir, File targetDir, String path, String cdnPath)
+			throws IOException {
+
+		if (cdnPath.indexOf('?') >= 0) {
+			cdnPath = cdnPath.substring(0, cdnPath.indexOf('?'));
+		}
+		if (cdnPath.indexOf('#') >= 0) {
+			cdnPath = cdnPath.substring(0, cdnPath.indexOf('#'));
+		}
+
+		File resource = new File(sourceDir, cdnPath);
+		File target = new File(targetDir, cdnPath);
+		if (!resource.exists()) {
+			// report but still copy the rest
+			log.warn("Resource not found: "+resource.getAbsolutePath());
+			try {
+				if (target.isFile() && target.exists() && target.delete()) {
+					log.info("Deleted existing: "+target.getAbsolutePath());
+				}
+			} catch (Exception ex) {
+				log.warn(ex.getMessage(), ex);
+			}
+			return;
+		}
+		if (!resource.isFile()) {
+			// report but still copy the rest
+			log.warn("Resource not a file: "+resource.getPath());
+			try {
+				if (target.isFile() && target.exists() && target.delete()) {
+					log.info("Deleted existing: "+target.getAbsolutePath());
+				}
+			} catch (Exception ex) {
+				log.warn(ex.getMessage(), ex);
+			}
+			return;
+		}
+
+		log.info("Copying "+path+" as "+cdnPath);
+		FileUtil.copy(resource, target, true, buffer);
+	}
+
+	/**
+	 * @return the view class
+	 * @throws ClassNotFoundException 
+	 */
+	private static Class<? extends DuelView> viewClass(String serverPrefix, String viewName)
+			throws ClassNotFoundException {
+
+		if (serverPrefix != null && !serverPrefix.isEmpty()) {
+			if (serverPrefix.endsWith(".")) {
+				viewName = serverPrefix + viewName;
+			} else {
+				viewName = serverPrefix + '.' + viewName;
+			}
+		}
+		return Class.forName(viewName).asSubclass(DuelView.class);
+	}
+}

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/SiteConfig.java

+package org.duelengine.duel.staticapps;
+
+import java.io.File;
+import java.util.Map;
+
+import org.codehaus.jackson.annotate.JsonIgnoreProperties;
+import org.codehaus.jackson.annotate.JsonProperty;
+
+@JsonIgnoreProperties(ignoreUnknown=true)
+public class SiteConfig {
+
+	private String targetDir;
+	private String sourceDir;
+	private String serverPrefix;
+	private String cdnHost;
+	private String cdnMap;
+	private String cdnLinksMap;
+	private boolean isDevMode;
+	private Map<String, SiteViewPage> views;
+	private String[] files;
+
+	// derivative values
+
+	private File sourceDirFile;
+	private File targetDirFile;
+
+	/**
+	 * Gets the target directory
+	 */
+	@JsonProperty
+	public String targetDir() {
+		return targetDir;
+	}
+
+	/**
+	 * Sets the target directory
+	 */
+	@JsonProperty
+	public SiteConfig targetDir(String value) {
+		targetDir = value;
+
+		if (value == null || value.isEmpty()) {
+			targetDirFile = null;
+
+		} else {
+			targetDirFile = new File(targetDir);
+		}
+		return this;
+	}
+
+	/**
+	 * Gets the web app directory
+	 */
+	@JsonProperty
+	public String sourceDir() {
+		return sourceDir;
+	}
+
+	/**
+	 * Sets the web app directory
+	 */
+	@JsonProperty
+	public SiteConfig sourceDir(String value) {
+		sourceDir = value;
+
+		if (value == null || value.isEmpty()) {
+			sourceDirFile = null;
+
+		} else {
+			sourceDirFile = new File(sourceDir);
+		}
+		return this;
+	}
+
+	/**
+	 * @return the server-side package name
+	 */
+	@JsonProperty
+	public String serverPrefix() {
+		return serverPrefix;
+	}
+
+	/**
+	 * @value the server-side package name
+	 */
+	@JsonProperty
+	public SiteConfig serverPrefix(String value) {
+		serverPrefix = value;
+		return this;
+	}
+
+	/**
+	 * @return the CDN host name
+	 */
+	@JsonProperty
+	public String cdnHost() {
+		return cdnHost;
+	}
+
+	/**
+	 * @value the CDN host name
+	 */
+	@JsonProperty
+	public SiteConfig cdnHost(String value) {
+		cdnHost = value;
+		return this;
+	}
+
+	/**
+	 * @return the name of the CDN map
+	 */
+	@JsonProperty
+	public String cdnMap() {
+		return cdnMap;
+	}
+
+	/**
+	 * @value the name of the CDN map
+	 */
+	@JsonProperty
+	public SiteConfig cdnMap(String value) {
+		cdnMap = value;
+		return this;
+	}
+
+	/**
+	 * @return the name of the CDN links map
+	 */
+	@JsonProperty
+	public String cdnLinksMap() {
+		return cdnLinksMap;
+	}
+
+	/**
+	 * @value the name of the CDN links map
+	 */
+	@JsonProperty
+	public SiteConfig cdnLinksMap(String value) {
+		cdnLinksMap = value;
+		return this;
+	}
+
+	/**
+	 * @return if should operate in dev mode
+	 */
+	@JsonProperty
+	public boolean isDevMode() {
+		return isDevMode;
+	}
+
+	/**
+	 * @value if should operate in dev mode
+	 */
+	@JsonProperty
+	public SiteConfig isDevMode(boolean value) {
+		isDevMode = value;
+		return this;
+	}
+
+	/**
+	 * Gets the views to generate
+	 */
+	@JsonProperty
+	public Map<String, SiteViewPage> views() {
+		return views;
+	}
+
+	/**
+	 * Sets the views to generate
+	 */
+	@JsonProperty
+	public SiteConfig views(Map<String, SiteViewPage> value) {
+		views = value;
+		return this;
+	}
+
+	/**
+	 * Gets the static files to copy
+	 */
+	@JsonProperty
+	public String[] files() {
+		return files;
+	}
+
+	/**
+	 * Sets the static files to copy
+	 */
+	@JsonProperty
+	public SiteConfig files(String[] value) {
+		files = value;
+		return this;
+	}
+
+	/* derived helpers -------------------------------------------*/
+
+	/**
+	 * @return the web app directory
+	 */
+	public File sourceDirFile() {
+		return sourceDirFile;
+	}
+
+	/**
+	 * @return the target output directory
+	 */
+	public File targetDirFile() {
+		return targetDirFile;
+	}
+}

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/SiteViewPage.java

+package org.duelengine.duel.staticapps;
+
+import java.util.Map;
+
+import org.codehaus.jackson.annotate.JsonProperty;
+
+public class SiteViewPage {
+
+	private String view;
+	private Object data;
+	private Map<String, Object> extras;
+
+	/**
+	 * @return the view name
+	 */
+	@JsonProperty
+	public String view() {
+		return view;
+	}
+
+	/**
+	 * @value the view name
+	 */
+	@JsonProperty
+	public SiteViewPage view(String value) {
+		view = value;
+		return this;
+	}
+
+	/**
+	 * Gets the view data
+	 */
+	@JsonProperty
+	public Object data() {
+		return data;
+	}
+
+	/**
+	 * Sets the view data
+	 */
+	@JsonProperty
+	public SiteViewPage data(Object value) {
+		data = value;
+		return this;
+	}
+
+	/**
+	 * Gets the ambient data extras
+	 */
+	@JsonProperty
+	public Map<String, Object> extras() {
+		return extras;
+	}
+
+	/**
+	 * Sets the ambient data extras
+	 */
+	@JsonProperty
+	public SiteViewPage extras(Map<String, Object> value) {
+		extras = value;
+		return this;
+	}
+}

duel-staticapps/src/main/java/org/duelengine/duel/staticapps/StaticLinkInterceptor.java

+package org.duelengine.duel.staticapps;
+
+import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.ResourceBundle;
+
+import org.duelengine.duel.CDNLinkInterceptor;
+
+public class StaticLinkInterceptor extends CDNLinkInterceptor {
+
+	private final Map<String, String> cache = new HashMap<String, String>();
+	private final ResourceBundle linksBundle;
+
+	public StaticLinkInterceptor(String cdnHost, ResourceBundle cdnBundle, ResourceBundle linksBundle, boolean isDevMode)
+			throws URISyntaxException {
+
+		super(cdnHost, cdnBundle, isDevMode);
+
+		// TODO: replace with: bundleAsMap(linksBundle, isDevMode)
+		this.linksBundle = linksBundle;
+	}
+
+	public Map<String, String> getLinkCache() {
+		return cache;
+	}
+
+	@Override
+	public String transformURL(String url) {
+		if (cache.containsKey(url)) {
+			return cache.get(url);
+		}
+
+		// intercept requests for transformation
+		String cdnURL = super.transformURL(url);
+
+		// collect an accumulated list
+		cache.put(url, cdnURL);
+
+		// recursively transform and cache child links
+		if (linksBundle != null && linksBundle.containsKey(url)) {
+			String childLinks = linksBundle.getString(url);
+			if (childLinks != null && !childLinks.isEmpty()) {
+				for (String child : childLinks.split("\\|")) {
+					if (child == null || child.isEmpty()) {
+						continue;
+					}
+
+					// ignore result, we only care about caching
+					this.transformURL(child);
+				}
+			}
+		}
+
+		return cdnURL;
+	}
+}

duel-staticapps/src/main/java/org/duelengine/duel/utils/FileUtil.java

+package org.duelengine.duel.utils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+
+import org.apache.commons.codec.binary.Base64;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class FileUtil {
+
+	public enum HashEncoding {
+		DEFAULT,
+		HEX,
+		BASE64
+	}
+
+	private static final Logger log = LoggerFactory.getLogger(FileUtil.class);
+	private static final int DEFAULT_BUFFER_SIZE = 4096;
+	private static final int SHA1_HEX_LENGTH = 40;
+	private static final int SHA1_BASE64_LENGTH = 27;
+	private static final int MD5_HEX_LENGTH = 32;
+	private static final int MD5_BASE64_LENGTH = 22;
+	public static final String SHA1 = "SHA-1";
+	public static final String MD5 = "MD5";
+
+	public static void prepSavePath(File file) {
+		if (file == null) {
+			throw new NullPointerException("file");
+		}
+
+		if (!file.getParentFile().exists()) {
+			file.getParentFile().mkdirs();
+		}
+	}
+
+	public static void copy(File source, File target, boolean overwrite)
+			throws IOException {
+
+		copy(source, target, overwrite, null);
+	}
+
+	public static void copy(File source, File target, boolean overwrite, byte[] buffer)
+			throws IOException {
+
+		if (target == null) {
+			throw new NullPointerException("target");
+		}
+		if (source == null) {
+			throw new NullPointerException("source");
+		}
+		if (!source.exists()) {
+			throw new FileNotFoundException(source.toString());
+		}
+		if (!source.isFile()) {
+			return;
+		}
+		if (!overwrite && target.exists()) {
+			return;
+		}
+		if (buffer == null) {
+			buffer = new byte[DEFAULT_BUFFER_SIZE];
+		}
+
+		prepSavePath(target);
+		
+		FileInputStream sourceStream = null;
+		FileOutputStream targetStream = null;
+		try {
+			sourceStream = new FileInputStream(source);
+			targetStream = new FileOutputStream(target);
+
+			int count;
+			while ((count = sourceStream.read(buffer)) > 0) {
+				targetStream.write(buffer, 0, count);
+			}
+
+		} finally {
+			if (targetStream != null) {
+				try {
+					targetStream.close();
+				} catch (IOException e) {}
+			}
+			if (sourceStream != null) {
+				try {
+					sourceStream.close();
+				} catch (IOException e) {}
+			}
+		}
+	}
+
+	public static List<File> findFiles(File root, String... extensions) {
+		if (extensions == null || extensions.length < 1) {
+			// no filter, returns all files
+			return findFiles(root, (Set<String>)null);
+		}
+
+		Set<String> extSet = new HashSet<String>(extensions.length);
+		for (String ext : extensions) {
+			extSet.add(ext);
+		}
+
+		return findFiles(root, extSet);
+	}
+
+	public static List<File> findFiles(File root, Set<String> extensions) {
+
+		// no filter, returns all files
+		boolean noFilter = (extensions == null || extensions.size() < 1);
+
+		List<File> files = new ArrayList<File>();
+		Queue<File> dirs = new LinkedList<File>();
+		dirs.add(root);
+		while (!dirs.isEmpty()) {
+			File file = dirs.remove();
+
+			if (file.isDirectory()) {
+				dirs.addAll(Arrays.asList(file.listFiles()));
+				continue;
+			}
+
+			String ext = getExtension(file.getName());
+			if (noFilter || extensions.contains(ext)) {
+				files.add(file);
+			} else {
+				log.info("Skipping: "+file.getName());
+			}
+		}
+
+		if (files.size() < 1) {
+			log.warn("No input files found.");
+		}
+		return files;
+	}
+
+	public static String getRelativePath(File root, File child) {
+		String prefix;
+		try {
+			prefix = root.getCanonicalPath();
+		} catch (IOException e) {
+			prefix = root.getAbsolutePath();
+		}
+
+		String path;
+		try {
+			path = child.getCanonicalPath();
+		} catch (IOException e) {
+			path = child.getAbsolutePath();
+		}
+
+		if (path.indexOf(prefix) != 0) {
+			return child.getPath();
+		}
+		if (prefix.length() == path.length()) {
+			return "";
+		}
+
+		int start = prefix.length();
+		if (path.charAt(start) == '/') {
+			start++;
+		}
+		return path.substring(start);
+	}
+
+	public static String getExtension(File file) {
+		if (file == null) {
+			return "";
+		}
+
+		return getExtension(file.getName());
+	}
+	
+	public static String getExtension(String name) {
+		int dot = name.lastIndexOf('.');
+		if (dot < 0) {
+			return "";
+		}
+
+		return name.substring(dot).toLowerCase();
+	}
+
+	public static File replaceExtension(File file, String ext) {
+		if (file == null) {
+			throw new NullPointerException("file");
+		}
+		if (ext == null || ext.isEmpty()) {
+			throw new NullPointerException("ext");
+		}
+
+		String name = file.getName();
+		int dot = name.lastIndexOf('.');
+		String newName = (dot < 0) ? name : name.substring(0, dot);
+		if (ext.indexOf('.') != 0) {
+			ext = '.'+ext;
+		}
+		newName += ext;
+		return new File(file.getParentFile(), newName);
+	}
+
+	public static String calcSHA1(File file)
+			throws FileNotFoundException {
+
+		return calcHash(file, SHA1, HashEncoding.DEFAULT, null);
+	}
+
+	public static String calcMD5(File file)
+			throws FileNotFoundException {
+
+		return calcHash(file, MD5, HashEncoding.DEFAULT, null);
+	}
+
+	public static String calcHash(File file, String algorithm, HashEncoding encoding)
+			throws FileNotFoundException {
+
+		return calcHash(file, algorithm, encoding, null);
+	}
+
+	public static String calcHash(File file, String algorithm, HashEncoding encoding, byte[] buffer)
+			throws FileNotFoundException {
+
+		if (file == null) {
+			throw new NullPointerException("file");
+		}
+		if (!file.exists()) {
+			throw new FileNotFoundException(file.toString());
+		}
+		if (buffer == null) {
+			buffer = new byte[DEFAULT_BUFFER_SIZE];
+		}
+
+		FileInputStream stream = null;
+		try {
+			MessageDigest hash = MessageDigest.getInstance(algorithm);
+			stream = new FileInputStream(file);
+
+			int count;
+			while ((count = stream.read(buffer)) > 0) {
+				hash.update(buffer, 0, count);
+			}
+
+			byte[] digest = hash.digest();
+			switch (encoding) {
+				case BASE64:
+					return encodeBytesBase64(digest);
+				default:
+				case HEX:
+					return encodeBytesHex(digest);
+			}
+
+		} catch (Exception ex) {
+			log.error(algorithm+" Error", ex);
+			return null;
+
+		} finally {
+			if (stream != null) {
+				try {
+					stream.close();
+				} catch (IOException e) {}
+			}
+		}
+	}
+
+	public static boolean isSHA1(String signature) {
+		// validate signature length and content
+		if (signature == null) {
+			return false;
+		}
+
+		switch (signature.length()) {
+			case SHA1_HEX_LENGTH:
+				return isHex(signature);
+			case SHA1_BASE64_LENGTH:
+				return Base64.isBase64(signature);
+			default:
+				return false;
+		}
+	}
+
+	public static boolean isMD5(String signature) {
+		// validate signature length and content
+		if (signature == null) {
+			return false;
+		}
+
+		switch (signature.length()) {
+			case MD5_HEX_LENGTH:
+				return isHex(signature);
+			case MD5_BASE64_LENGTH:
+				return Base64.isBase64(signature);
+			default:
+				return false;
+		}
+	}
+
+	private static boolean isHex(String signature) {
+		if (signature == null || signature.isEmpty()) {
+			return false;
+		}
+		
+		for (int i=signature.length()-1; i>=0; i--) {
+			char ch = signature.charAt(i);
+			if ((ch >= '0' && ch <= '9') ||
+				(ch >= 'a' && ch <= 'f') ||
+				(ch >= 'A' && ch <= 'F'))
+			{
+				continue;
+			}
+	
+			return false;
+		}
+
+		return true;
+	}
+
+	private static String encodeBytesBase64(byte[] digest) {
+		return Base64.encodeBase64URLSafeString(digest);
+	}
+
+	private static String encodeBytesHex(byte[] digest) {
+		StringBuilder hex = new StringBuilder(SHA1_HEX_LENGTH);
+		for (int i=0; i<digest.length; i++) {
+			int digit = 0xFF & digest[i];
+			if (digit < 0x10) {
+				hex.append('0');
+			}
+			hex.append(Integer.toHexString(digit));
+		}
+		return hex.toString();
+	}
+}
 		<module>duel-js</module>
 		<module>duel-runtime</module>
 		<module>duel-compiler</module>
+		<module>duel-staticapps</module>
 		<module>duel-maven-plugin</module>
 	</modules>
 </project>