Commits

Stephen McKamey  committed 79bdd53

removing additional root directory

  • Participants
  • Parent commits e64f398

Comments (0)

Files changed (29)

 \.iml
 \.ipr
 \.iws
-merge/merge-builder/target
-merge/merge-maven-plugin/target
+merge-builder/target
+merge-maven-plugin/target
+The MIT License
+
+Copyright (c) 2006-2011 Stephen M. McKamey
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.

File merge-builder/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>
+
+	<groupId>org.duelengine</groupId>
+	<artifactId>merge-builder</artifactId>
+	<version>0.2.0</version>
+	<packaging>jar</packaging>
+
+	<name>DUEL Merge Builder</name>
+	<description>Client-side resource management</description>
+	<url>http://duelengine.org</url>
+	<licenses>
+		<license>
+			<name>MIT License</name>
+			<url>https://bitbucket.org/mckamey/merge/src/tip/duel-merge/LICENSE.txt</url>
+		</license>
+	</licenses>
+	<scm>
+		<url>https://bitbucket.org/mckamey/duel-merge</url>
+		<connection>scm:hg:https://bitbucket.org/mckamey/duel-merge</connection>
+		<developerConnection>scm:hg:https://bitbucket.org/mckamey/duel-merge</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>
+	</properties>
+
+	<dependencies>
+		<!-- CSS compaction -->
+		<dependency>
+			<groupId>org.cssless</groupId>
+			<artifactId>css</artifactId>
+			<version>0.3.0</version>
+		</dependency>
+
+		<!-- JavaScript compaction -->
+		<dependency>
+			<groupId>com.google.javascript</groupId>
+			<artifactId>closure-compiler</artifactId>
+			<version>[r1043,)</version>
+		</dependency>
+
+		<dependency>
+			<groupId>junit</groupId>
+			<artifactId>junit</artifactId>
+			<version>4.8.2</version>
+			<scope>test</scope>
+		</dependency>
+	</dependencies>
+
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-jar-plugin</artifactId>
+				<version>2.3.1</version>
+				<configuration>
+					<archive>
+						<manifest>
+							<mainClass>org.duelengine.merge.MergeBuilder</mainClass>
+						</manifest>
+					</archive>
+				</configuration>
+			</plugin>
+		</plugins>
+		<pluginManagement>
+			<plugins>
+				<plugin>
+					<artifactId>maven-compiler-plugin</artifactId>
+					<version>2.3.2</version>
+					<configuration>
+						<source>1.6</source>
+						<target>1.6</target>
+					</configuration>
+				</plugin>
+				<plugin>
+					<artifactId>maven-surefire-plugin</artifactId>
+					<version>2.7.2</version>
+					<configuration>
+						<includes>
+							<!-- TODO: rename tests so they conform to **/*Test.java -->
+							<include>**/*Tests.java</include>
+						</includes>
+					</configuration>
+				</plugin>
+			</plugins>
+		</pluginManagement>
+	</build>
+</project>

File merge-builder/src/main/java/org/duelengine/merge/CSSCompactor.java

+package org.duelengine.merge;
+
+import java.io.*;
+import java.util.Map;
+
+import org.cssless.css.codegen.CodeGenSettings;
+import org.cssless.css.compiler.*;
+
+class CSSCompactor implements Compactor {
+
+	private final CssCompiler compiler = new CssCompiler();
+	private final CodeGenSettings settings = new CodeGenSettings();
+
+	@Override
+	public String[] getSourceExtensions() {
+		return new String[] { ".css", ".less" };
+	}
+
+	@Override
+	public String getTargetExtension() {
+		return ".css";
+	}
+
+	@Override
+	public void compact(Map<String, String> fileHashes, File source, File target) throws IOException {
+		target.getParentFile().mkdirs();
+		this.compiler.process(
+			source,
+			target,
+			this.settings,
+			new LinkInterceptorCssFilter(fileHashes));
+	}
+}

File merge-builder/src/main/java/org/duelengine/merge/CSSPlaceholderGenerator.java

+package org.duelengine.merge;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.List;
+
+public class CSSPlaceholderGenerator implements PlaceholderGenerator {
+
+	@Override
+	public String getTargetExtension() {
+		return ".css";
+	}
+
+	@Override
+	public void build(File target, List<String> children) throws IOException {
+		target.getParentFile().mkdirs();
+		FileWriter writer = new FileWriter(target, false);
+
+		try {
+			// concatenate references to children
+			for (String child : children) {
+				// insert child files into outputFile
+				writer.append("@import url(").append(child).append(");\n");
+			}
+
+		} finally {
+			writer.flush();
+			writer.close();
+		}
+	}
+}

File merge-builder/src/main/java/org/duelengine/merge/Compactor.java

+package org.duelengine.merge;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+
+public interface Compactor {
+
+	/**
+	 * Gets the extension which this compaction consumes
+	 * @return
+	 */
+	String[] getSourceExtensions();
+
+	/**
+	 * Gets the extension which this compaction emits
+	 * @return
+	 */
+	String getTargetExtension();
+
+	/**
+	 * Perform compaction
+	 * @param source input file
+	 * @param target output file
+	 */
+	void compact(Map<String, String> fileHashes, File source, File target) throws IOException;
+}

File merge-builder/src/main/java/org/duelengine/merge/JSCompactor.java

+package org.duelengine.merge;
+
+import java.io.*;
+import java.util.*;
+import java.util.logging.Level;
+
+import com.google.javascript.jscomp.*;
+import com.google.javascript.jscomp.Compiler;
+
+class JSCompactor implements Compactor {
+
+	@Override
+	public String[] getSourceExtensions() {
+		return new String[] { ".js" };
+	}
+
+	@Override
+	public String getTargetExtension() {
+		return ".js";
+	}
+
+	@Override
+	public void compact(Map<String, String> fileHashes, File source, File target) throws IOException {
+
+		// adapted from http://blog.bolinfest.com/2009/11/calling-closure-compiler-from-java.html
+		CompilerOptions options = new CompilerOptions();
+
+		// Simple mode is used here, but additional options could be set, too.
+		CompilationLevel.SIMPLE_OPTIMIZATIONS.setOptionsForCompilationLevel(options);
+
+		// only log warnings
+		Compiler.setLoggingLevel(Level.WARNING);
+
+		Compiler compiler = new Compiler();
+
+		List<JSSourceFile> externs = CommandLineRunner.getDefaultExterns();
+		List<JSSourceFile> inputs = Collections.singletonList(JSSourceFile.fromFile(source));
+
+		// compile() returns a Result, but it is not needed here.
+		compiler.compile(externs, inputs, options);
+
+		// compiler is responsible for generating the compiled code
+		// it is not accessible via the Result
+		String result = compiler.toSource();
+
+		target.getParentFile().mkdirs();
+		FileWriter writer = new FileWriter(target, false);
+		try {
+			writer.append(result);
+		} finally {
+			writer.flush();
+			writer.close();
+		}
+	}
+}

File merge-builder/src/main/java/org/duelengine/merge/JSPlaceholderGenerator.java

+package org.duelengine.merge;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.List;
+
+public class JSPlaceholderGenerator implements PlaceholderGenerator {
+
+	@Override
+	public String getTargetExtension() {
+		return ".js";
+	}
+
+	@Override
+	public void build(File target, List<String> children) throws IOException {
+		target.getParentFile().mkdirs();
+		FileWriter writer = new FileWriter(target, false);
+
+		try {
+			writer.append("(function() {\n\tvar s, d=document, f=d.getElementsByTagName('script')[0], p=f.parentNode;\n");
+
+			// concatenate references to children
+			for (String child : children) {
+				// insert child files into outputFile
+				writer
+					.append("\ts=d.createElement('script');s.type='text/javascript';s.src='")
+					.append(child.replace("'", "\\'"))
+					.append("';p.insertBefore(s,f);\n");
+			}
+
+			writer.append("})();");
+
+		} finally {
+			writer.flush();
+			writer.close();
+		}
+	}
+}

File merge-builder/src/main/java/org/duelengine/merge/LinkInterceptorCssFilter.java

+package org.duelengine.merge;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.logging.Logger;
+
+import org.cssless.css.ast.*;
+import org.cssless.css.codegen.CssFilter;
+import org.cssless.css.parsing.CssLexer;
+
+public class LinkInterceptorCssFilter implements CssFilter {
+
+	private final Logger log = Logger.getLogger(LinkInterceptorCssFilter.class.getCanonicalName());
+	private final Map<String, String> linkMap;
+
+	public LinkInterceptorCssFilter(Map<String, String> linkMap) {
+		this.linkMap = linkMap;
+	}
+	
+	@Override
+	public CssNode filter(CssNode node) {
+		if (node.getNodeType() != CssNodeType.FUNCTION) {
+			return node;
+		}
+
+		FunctionNode func = (FunctionNode)node;
+		if (!"url".equals(func.getValue())) {
+			return node;
+		}
+
+		ContainerNode children = func.getContainer();
+
+		if (children.childCount() > 1) {
+			// HACK: we need a consolidated value rather than expression
+			StringBuilder buffer = new StringBuilder();
+			try {
+				new org.cssless.css.codegen.CssFormatter().writeNode(buffer, children, null);
+			} catch (IOException e) {
+				return node;
+			}
+			String value = buffer.toString();
+			if (value == null || value.isEmpty()) {
+				return node;
+			}
+			children.getChildren().clear();
+			children.getChildren().add(new ValueNode(value));
+		}
+
+		// should be only one child
+		for (CssNode child : children.getChildren()) {
+			if (child instanceof ValueNode) {
+				ValueNode valNode = ((ValueNode)child);
+				String val = valNode.getValue();
+				val = CssLexer.decodeString(val);
+				if (val == null || val.isEmpty()) {
+					break;
+				}
+
+				String valHash = this.linkMap.get(val);
+				if (valHash == null) {
+					log.warning("Missing CSS reference: "+val);
+					break;
+				}
+
+				// trim path to just filename
+				// this will make URL relative from stylesheet
+				valHash = valHash.substring(valHash.lastIndexOf('/')+1);
+
+				if (child instanceof StringNode) {
+					valHash = CssLexer.encodeString(valHash);
+				}
+				valNode.setValue(valHash);
+				log.info("CSS url: "+val+" => "+valHash);
+
+			} else {
+				log.warning("Unexpected CSS url type: "+child.getNodeType());
+			}
+		}
+		
+		return node;
+	}
+}

File merge-builder/src/main/java/org/duelengine/merge/MergeBuilder.java

+package org.duelengine.merge;
+
+import java.io.*;
+import java.security.*;
+import java.util.*;
+import java.util.logging.Logger;
+
+public class MergeBuilder {
+
+	private static final String HELP =
+		"Usage:\n" +
+		"\tjava -jar merge.jar <webapp-dir>\n" +
+		"\tjava -jar merge.jar <webapp-dir> <cdn-output-path>\n" +
+		"\tjava -jar merge.jar <webapp-dir> <cdn-output-path> <cdn-map-file>\n\n"+
+		"\twebapp-dir: file path to the root of the webapp\n"+
+		"\tcdn-output-path: webapp-relative path for the cdn output\n"+
+		"\tcdn-map-file: path to the generated resource file\n";
+
+	private static final int BUFFER_SIZE = 4096;
+	private static final String HASH_ALGORITHM = "SHA-1";
+	private static final String CHAR_ENCODING = "utf-8";
+
+	public static void main(String[] args) {
+		if (args.length < 1) {
+			System.out.println(HELP);
+			return;
+		}
+
+		MergeBuilder builder = new MergeBuilder();
+		builder.setWebAppDir(args[0]);
+
+		if (args.length > 1) {
+			builder.setCDNRoot(args[1]);
+
+			if (args.length > 2) {
+				builder.setCDNMapFile(args[2]);
+			}
+		}
+
+		try {
+			builder.execute();
+
+		} catch (Exception e) {
+			e.printStackTrace(System.err);
+		}
+	}
+
+	private final Logger log = Logger.getLogger(MergeBuilder.class.getCanonicalName());
+	private final Map<String, Compactor> compactors;
+	private final Map<String, PlaceholderGenerator> placeholders;
+	private File webappDir;
+	private String cdnRoot;
+	private File cdnMapFile;
+
+	public MergeBuilder(String... cdnExtensions) {
+		this(Arrays.asList(
+				new JSPlaceholderGenerator(),
+				new CSSPlaceholderGenerator()),
+			Arrays.asList(
+				new NullCompactor(cdnExtensions),
+				new CSSCompactor(),
+				new JSCompactor()));
+	}
+
+	public MergeBuilder(List<PlaceholderGenerator> placeholders, List<Compactor> compactors) {
+		if (placeholders == null) {
+			throw new NullPointerException("placeholders");
+		}
+		if (compactors == null) {
+			throw new NullPointerException("compactors");
+		}
+
+		this.placeholders = new LinkedHashMap<String, PlaceholderGenerator>(placeholders.size());
+		if (placeholders != null) {
+			for (PlaceholderGenerator placeholder : placeholders) {
+				this.placeholders.put(placeholder.getTargetExtension(), placeholder);
+			}
+		}
+
+		this.compactors = new LinkedHashMap<String, Compactor>(compactors.size());
+		for (Compactor compactor : compactors) {
+			for (String ext : compactor.getSourceExtensions()) {
+				this.compactors.put(ext, compactor);
+			}
+		}
+	}
+
+	public String getWebAppDir() {
+		return this.webappDir.getAbsolutePath();
+	}
+
+	public void setWebAppDir(String value) {
+		this.webappDir = (value != null) ? new File(value.replace('\\', '/')) : null;
+	}
+
+	public String getCDNRoot() {
+		return this.cdnRoot;
+	}
+
+	public void setCDNRoot(String value) {
+		if (value != null) {
+			value = value.replace('\\', '/');
+			if (!value.startsWith("/")) {
+				value = '/'+value;
+			}
+			if (!value.endsWith("/")) {
+				value += '/';
+			}
+		}
+		this.cdnRoot = value;
+	}
+
+	public String getCDNMapFile() {
+		return this.cdnMapFile.getAbsolutePath();
+	}
+
+	public void setCDNMapFile(String value) {
+		this.cdnMapFile = (value != null) ? new File(value.replace('\\', '/')) : null;
+	}
+
+	private boolean ensureSettings() {
+		if (this.webappDir == null || !this.webappDir.exists()) {
+			throw new IllegalArgumentException("Error: missing webapp "+this.webappDir);
+		}
+
+		if (this.cdnRoot == null) {
+			this.cdnRoot = "/cdn/";
+		}
+
+		if (this.cdnMapFile == null) {
+			this.cdnMapFile = new File(this.webappDir.getParentFile(), "resources/cdn.properties");
+		}
+
+		return true;
+	}
+
+	/**
+	 * Compiles merge files
+	 * @throws IOException 
+	 * @throws NoSuchAlgorithmException 
+	 */
+	public void execute() throws IOException, NoSuchAlgorithmException {
+		if (!this.ensureSettings()) {
+			return;
+		}
+
+		final Map<String, String> hashLookup = new LinkedHashMap<String, String>();
+
+		// calculate hash and compact all the source files
+		for (String ext : this.compactors.keySet()) {
+			hashClientFiles(hashLookup, ext);
+		}
+
+		if (hashLookup.size() < 1) {
+			throw new IllegalArgumentException("Error: no input files found in "+this.webappDir);
+		}
+
+		// calculate hash for all the merge files and determine dependencies
+		Map<String, List<String>> dependencyMap = this.hashMergeFiles(hashLookup);
+
+		for (String path : dependencyMap.keySet()) {
+			List<String> children = dependencyMap.get(path);
+
+			this.buildMerge(hashLookup, path, children);
+			this.buildDevPlaceholders(hashLookup, path, children);
+		}
+
+		saveHashLookup(hashLookup);
+	}
+
+	private void buildMerge(final Map<String, String> hashLookup, String path, List<String> children)
+		throws FileNotFoundException, IOException {
+
+		log.info("Building "+path);
+		String outputPath = hashLookup.get(path);
+
+		File outputFile = new File(this.webappDir, outputPath);
+		if (outputFile.exists()) {
+			log.info("- exists: "+outputPath);
+			return;
+		}
+		log.info("- writing to "+outputPath);
+
+		outputFile.getParentFile().mkdirs();
+		FileWriter writer = new FileWriter(outputFile, false);
+
+		try {
+			// concatenate children
+			final char[] buffer = new char[BUFFER_SIZE];
+			for (String child : children) {
+				// insert child files into outputFile
+				log.info("- adding "+child);
+				File inputFile = new File(this.webappDir, hashLookup.get(child));
+				FileReader reader = new FileReader(inputFile);
+				try {
+					int count;
+					while ((count = reader.read(buffer)) > 0) {
+						writer.write(buffer, 0, count);
+					}
+				} finally {
+					reader.close();
+				}
+			}
+
+		} finally {
+			writer.flush();
+			writer.close();
+		}
+	}
+
+	private void buildDevPlaceholders(final Map<String, String> hashLookup, String path, List<String> children)
+		throws FileNotFoundException, IOException {
+
+		String hashPath = hashLookup.get(path);
+		int slash = hashPath.lastIndexOf('/');
+
+		// insert dev dir
+		String devPath = hashPath.substring(0, slash)+"/dev"+hashPath.substring(slash);
+		hashLookup.put(hashPath, devPath);
+
+		File outputFile = new File(this.webappDir, devPath);
+		if (outputFile.exists()) {
+			return;
+		}
+
+		PlaceholderGenerator generator = this.placeholders.get(getExtension(hashPath));
+		if (generator == null) {
+			log.warning("Cannot generate placeholder for "+hashPath);
+			return;
+		}
+
+		generator.build(outputFile, children);
+	}
+
+	private Map<String, List<String>> hashMergeFiles(final Map<String, String> hashLookup)
+			throws IOException, NoSuchAlgorithmException {
+
+		final int rootPrefix = this.webappDir.getCanonicalPath().length();
+		final List<File> inputFiles = findFiles(this.webappDir, ".merge", this.cdnRoot);
+		final Map<String, List<String>> dependencyMap = new LinkedHashMap<String, List<String>>(inputFiles.size());
+
+		for (File inputFile : inputFiles) {
+			List<String> children = new ArrayList<String>();
+
+			String hashPath = this.cdnRoot+calcMergeHash(inputFile, children, hashLookup);
+
+			// merge file takes the first non-empty extension
+			String ext = null;
+			for (String child : children) {
+				ext = getExtension(child);
+				if (ext != null) {
+					break;
+				}
+			}
+			if (ext == null) {
+				ext = ".merge";
+			}
+			hashPath += ext;
+
+			String path = inputFile.getCanonicalPath().substring(rootPrefix);
+			hashLookup.put(path, hashPath);
+			dependencyMap.put(path, children);
+		}
+
+		return dependencyMap;
+	}
+
+	private void hashClientFiles(final Map<String, String> hashLookup, String ext)
+			throws IOException, NoSuchAlgorithmException {
+
+		final int rootPrefix = this.webappDir.getCanonicalPath().length();
+		final Compactor compactor = compactors.get(ext);
+		if (compactor == null) {
+			throw new IllegalArgumentException("Error: no compactor registered for "+ext);
+		}
+		String targetExt = compactor.getTargetExtension();
+		if (targetExt == null || targetExt.indexOf('.') < 0) {
+			targetExt = ext;
+		}
+
+		final List<File> inputFiles = findFiles(this.webappDir, ext, this.cdnRoot);
+
+		for (File inputFile : inputFiles) {
+
+			// calculate and store the hash
+			String hashPath = this.cdnRoot + this.calcFileHash(inputFile) + targetExt;
+			String path = inputFile.getCanonicalPath().substring(rootPrefix);
+			hashLookup.put(path, hashPath);
+
+			// ensure all the client files have been compacted
+			File outputFile = new File(this.webappDir, hashPath);
+			if (!outputFile.exists()) {
+				// ensure compacted target path exists
+				compactor.compact(hashLookup, inputFile, outputFile);
+			}
+
+			if (!outputFile.exists()) {
+				// file still missing, remove
+				log.severe(path+" failed to compact");
+				hashLookup.remove(path);
+
+			} else if (outputFile.length() < 1L) {
+				// special case for files which compact to empty
+				log.warning(path+" compacted to an empty file");
+				hashLookup.remove(path);
+			}
+		}
+	}
+
+	private void saveHashLookup(final Map<String, String> hashLookup)
+			throws IOException {
+
+		this.cdnMapFile.getParentFile().mkdirs();
+
+		final String newline = System.getProperty("line.separator");
+		FileWriter writer = new FileWriter(this.cdnMapFile, false);
+		try {
+			// generate output
+			for (String key : hashLookup.keySet()) {
+				// http://download.oracle.com/javase/6/docs/api/java/util/Properties.html#load(java.io.Reader)
+				// TODO: escape any illegal chars [:=#!\s]+
+				writer.append(key).append('=').append(hashLookup.get(key)).append(newline);
+			}
+
+		} finally {
+			writer.flush();
+			writer.close();
+		}
+	}
+
+	private String calcMergeHash(File inputFile, List<String> children, final Map<String, String> hashLookup)
+			throws NoSuchAlgorithmException, FileNotFoundException, IOException, UnsupportedEncodingException {
+
+		FileReader reader = new FileReader(inputFile);
+		try {
+			BufferedReader lineReader = new BufferedReader(reader);
+
+			// calculate the hash for the merge file
+			// will be a hash of the child hashes
+			final MessageDigest sha1 = MessageDigest.getInstance(HASH_ALGORITHM);
+
+			String line;
+			while ((line = lineReader.readLine()) != null) {
+				line = line.trim();
+				if (line.isEmpty() || line.startsWith("#")) {
+					// skip empty lines and comments
+					continue;
+				}
+
+				String childPath = hashLookup.get(line);
+				if (childPath == null) {
+					// TODO: allow chaining of .merge files by ordering by dependency
+					log.warning("Missing merge reference: "+line);
+
+					// skip missing resources (will be reflected in hash)
+					continue;
+				}
+
+				children.add(line);
+				sha1.update(childPath.getBytes(CHAR_ENCODING));
+			}
+
+			return encodeBytes(sha1.digest());
+
+		} finally {
+			reader.close();
+		}
+	}
+
+	private String calcFileHash(File inputFile)
+			throws IOException, NoSuchAlgorithmException {
+
+		FileInputStream stream = new FileInputStream(inputFile);
+		try {
+			final MessageDigest sha1 = MessageDigest.getInstance(HASH_ALGORITHM);
+			final byte[] buffer = new byte[BUFFER_SIZE];
+
+			int count;
+			while ((count = stream.read(buffer)) > 0) {
+				sha1.update(buffer, 0, count);
+			}
+
+			return encodeBytes(sha1.digest());
+
+		} finally {
+			stream.close();
+		}
+	}
+
+	private static String encodeBytes(byte[] digest) {
+		StringBuilder hex = new StringBuilder();
+		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();
+	}
+
+	private static String getExtension(String path) {
+		int dot = path.lastIndexOf('.');
+		if (dot < 0) {
+			return "";
+		}
+
+		return path.substring(dot);
+	}
+
+	private static List<File> findFiles(File webappDir, String ext, String cdnFolder)
+			throws IOException {
+
+		final String outputPath = webappDir.getCanonicalPath()+cdnFolder;
+		List<File> files = new ArrayList<File>();
+		Queue<File> folders = new LinkedList<File>();
+		folders.add(webappDir);
+
+		while (!folders.isEmpty()) {
+			File file = folders.poll();
+			if (file.getCanonicalPath().startsWith(outputPath)) {
+				// filter the output
+				continue;
+			}
+			if (file.isDirectory()) {
+				folders.addAll(Arrays.asList(file.listFiles()));
+			} else if (file.getName().toLowerCase().endsWith(ext)) {
+				files.add(file);
+			}
+		}
+
+		return files;
+	}
+}

File merge-builder/src/main/java/org/duelengine/merge/NullCompactor.java

+package org.duelengine.merge;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Very basic "compactor" which simply copies the bits from source to target
+ */
+class NullCompactor implements Compactor {
+
+	private static final int BUFFER_SIZE = 4096;
+	private final String[] extensions;
+
+	public NullCompactor(String... extensions) {
+		this.extensions = (extensions != null) ? extensions : new String[0];
+	}
+	
+	@Override
+	public String[] getSourceExtensions() {
+		return this.extensions;
+	}
+
+	@Override
+	public String getTargetExtension() {
+		return null;
+	}
+
+	@Override
+	public void compact(Map<String, String> fileHashes, File source, File target) throws IOException {
+		target.getParentFile().mkdirs();
+
+		FileInputStream inStream = new FileInputStream(source);
+		FileOutputStream outStream = new FileOutputStream(target);
+		try {
+			final byte[] buffer = new byte[BUFFER_SIZE];
+
+			int count;
+			while ((count = inStream.read(buffer)) > 0) {
+				outStream.write(buffer, 0, count);
+			}
+
+		} finally {
+			outStream.flush();
+			outStream.close();
+			inStream.close();
+		}
+	}
+}

File merge-builder/src/main/java/org/duelengine/merge/PlaceholderGenerator.java

+package org.duelengine.merge;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+
+public interface PlaceholderGenerator {
+
+	/**
+	 * Gets the extension which this compaction emits
+	 * @return
+	 */
+	String getTargetExtension();
+
+	/**
+	 * Perform compaction
+	 * @param target output file
+	 * @param source input file
+	 */
+	void build(File target, List<String> children) throws IOException;
+}

File merge-maven-plugin/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>
+
+	<groupId>org.duelengine</groupId>
+	<artifactId>merge-maven-plugin</artifactId>
+	<version>0.2.0</version>
+	<packaging>maven-plugin</packaging>
+
+	<name>DUEL Merge Maven Plugin</name>
+	<description>Client-side resource management</description>
+	<url>http://duelengine.org</url>
+	<licenses>
+		<license>
+			<name>MIT License</name>
+			<url>https://bitbucket.org/mckamey/merge/src/tip/duel-merge/LICENSE.txt</url>
+		</license>
+	</licenses>
+	<scm>
+		<url>https://bitbucket.org/mckamey/duel-merge</url>
+		<connection>scm:hg:https://bitbucket.org/mckamey/duel-merge</connection>
+		<developerConnection>scm:hg:https://bitbucket.org/mckamey/duel-merge</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>
+	</properties>
+
+	<dependencies>
+		<dependency>
+			<groupId>org.duelengine</groupId>
+			<artifactId>merge-builder</artifactId>
+			<version>${project.version}</version>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.maven</groupId>
+			<artifactId>maven-core</artifactId>
+			<version>[2.0,4.0)</version>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.maven</groupId>
+			<artifactId>maven-plugin-api</artifactId>
+			<version>[2.0,4.0)</version>
+		</dependency>
+		<dependency>
+			<groupId>junit</groupId>
+			<artifactId>junit</artifactId>
+			<version>4.8.2</version>
+			<scope>test</scope>
+		</dependency>
+	</dependencies>
+
+	<build>
+		<plugins>
+			<plugin>
+				<artifactId>maven-plugin-plugin</artifactId>
+				<version>2.5.1</version>
+				<executions>
+					<execution>
+						<id>generated-helpmojo</id>
+						<goals>
+							<goal>helpmojo</goal>
+						</goals>
+					</execution>
+				</executions>
+				<configuration>
+					<goalPrefix>duel</goalPrefix>
+				</configuration>
+			</plugin>
+		</plugins>
+		<pluginManagement>
+			<plugins>
+				<plugin>
+					<artifactId>maven-compiler-plugin</artifactId>
+					<version>2.3.2</version>
+					<configuration>
+						<source>1.6</source>
+						<target>1.6</target>
+					</configuration>
+				</plugin>
+				<plugin>
+					<artifactId>maven-surefire-plugin</artifactId>
+					<version>2.7.2</version>
+					<configuration>
+						<includes>
+							<!-- TODO: rename tests so they conform to **/*Test.java -->
+							<include>**/*Tests.java</include>
+						</includes>
+					</configuration>
+				</plugin>
+			</plugins>
+		</pluginManagement>
+	</build>
+</project>

File merge-maven-plugin/src/main/java/org/duelengine/merge/maven/MergeMojo.java

+package org.duelengine.merge.maven;
+
+import java.util.Arrays;
+
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.logging.Log;
+import org.duelengine.merge.*;
+
+/**
+ * Generates client-side and server-side sources
+ *
+ * @goal merge
+ * @phase process-sources
+ */
+public class MergeMojo extends AbstractMojo {
+
+	// http://maven.apache.org/ref/3.0.2/maven-model/maven.html#class_build
+
+	/**
+	 * Location of the webapp.
+	 * 
+	 * @parameter default-value="${project.basedir}/src/main/webapp/"
+	 */
+	private String webappDir;
+
+	/**
+	 * Location of the generated CDN files.
+	 * 
+	 * @parameter default-value="/cdn/"
+	 */
+	private String cdnRoot;
+
+	/**
+	 * Location of the generated resources.
+	 * 
+	 * @parameter default-value="${project.basedir}/src/main/resources/cdn.properties"
+	 */
+	private String cdnMapFile;
+
+	/**
+	 * List of additional file extensions to hash and copy directly into CDN.
+	 * 
+	 * @parameter default-value=""
+	 */
+	private String cdnFiles;
+
+	public void execute()
+		throws MojoExecutionException {
+
+		Log log = this.getLog();
+		log.info("\twebappDir="+this.webappDir);
+		log.info("\tcdnRoot="+this.cdnRoot);
+		log.info("\tcdnMapFile="+this.cdnMapFile);
+
+		String[] exts;
+		if (this.cdnFiles != null) {
+			exts = this.cdnFiles.split("[|,\\s]+");
+		} else {
+			exts = new String[0];
+		}
+
+		log.info("\tcdnFiles="+Arrays.toString(exts));
+
+		MergeBuilder merger = new MergeBuilder(exts);
+		merger.setWebAppDir(this.webappDir);
+
+		if (this.cdnRoot != null && !this.cdnRoot.isEmpty()) {
+			merger.setCDNRoot(this.cdnRoot);
+		}
+
+		if (this.cdnMapFile != null && !this.cdnMapFile.isEmpty()) {
+			merger.setCDNMapFile(this.cdnMapFile);
+		}
+
+		try {
+			merger.execute();
+
+		} catch (Exception e) {
+			log.error(e);
+		}
+	}
+}

File merge/LICENSE.txt

-The MIT License
-
-Copyright (c) 2006-2011 Stephen M. McKamey
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.

File merge/merge-builder/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>
-
-	<groupId>org.duelengine</groupId>
-	<artifactId>merge-builder</artifactId>
-	<version>0.2.0</version>
-	<packaging>jar</packaging>
-
-	<name>DUEL Merge Builder</name>
-	<description>Client-side resource management</description>
-	<url>http://duelengine.org</url>
-	<licenses>
-		<license>
-			<name>MIT License</name>
-			<url>https://bitbucket.org/mckamey/merge/src/tip/duel-merge/LICENSE.txt</url>
-		</license>
-	</licenses>
-	<scm>
-		<url>https://bitbucket.org/mckamey/duel-merge</url>
-		<connection>scm:hg:https://bitbucket.org/mckamey/duel-merge</connection>
-		<developerConnection>scm:hg:https://bitbucket.org/mckamey/duel-merge</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>
-	</properties>
-
-	<dependencies>
-		<!-- CSS compaction -->
-		<dependency>
-			<groupId>org.cssless</groupId>
-			<artifactId>css</artifactId>
-			<version>0.3.0</version>
-		</dependency>
-
-		<!-- JavaScript compaction -->
-		<dependency>
-			<groupId>com.google.javascript</groupId>
-			<artifactId>closure-compiler</artifactId>
-			<version>[r1043,)</version>
-		</dependency>
-
-		<dependency>
-			<groupId>junit</groupId>
-			<artifactId>junit</artifactId>
-			<version>4.8.2</version>
-			<scope>test</scope>
-		</dependency>
-	</dependencies>
-
-	<build>
-		<plugins>
-			<plugin>
-				<groupId>org.apache.maven.plugins</groupId>
-				<artifactId>maven-jar-plugin</artifactId>
-				<version>2.3.1</version>
-				<configuration>
-					<archive>
-						<manifest>
-							<mainClass>org.duelengine.merge.MergeBuilder</mainClass>
-						</manifest>
-					</archive>
-				</configuration>
-			</plugin>
-		</plugins>
-		<pluginManagement>
-			<plugins>
-				<plugin>
-					<artifactId>maven-compiler-plugin</artifactId>
-					<version>2.3.2</version>
-					<configuration>
-						<source>1.6</source>
-						<target>1.6</target>
-					</configuration>
-				</plugin>
-				<plugin>
-					<artifactId>maven-surefire-plugin</artifactId>
-					<version>2.7.2</version>
-					<configuration>
-						<includes>
-							<!-- TODO: rename tests so they conform to **/*Test.java -->
-							<include>**/*Tests.java</include>
-						</includes>
-					</configuration>
-				</plugin>
-			</plugins>
-		</pluginManagement>
-	</build>
-</project>

File merge/merge-builder/src/main/java/org/duelengine/merge/CSSCompactor.java

-package org.duelengine.merge;
-
-import java.io.*;
-import java.util.Map;
-
-import org.cssless.css.codegen.CodeGenSettings;
-import org.cssless.css.compiler.*;
-
-class CSSCompactor implements Compactor {
-
-	private final CssCompiler compiler = new CssCompiler();
-	private final CodeGenSettings settings = new CodeGenSettings();
-
-	@Override
-	public String[] getSourceExtensions() {
-		return new String[] { ".css", ".less" };
-	}
-
-	@Override
-	public String getTargetExtension() {
-		return ".css";
-	}
-
-	@Override
-	public void compact(Map<String, String> fileHashes, File source, File target) throws IOException {
-		target.getParentFile().mkdirs();
-		this.compiler.process(
-			source,
-			target,
-			this.settings,
-			new LinkInterceptorCssFilter(fileHashes));
-	}
-}

File merge/merge-builder/src/main/java/org/duelengine/merge/CSSPlaceholderGenerator.java

-package org.duelengine.merge;
-
-import java.io.File;
-import java.io.FileWriter;
-import java.io.IOException;
-import java.util.List;
-
-public class CSSPlaceholderGenerator implements PlaceholderGenerator {
-
-	@Override
-	public String getTargetExtension() {
-		return ".css";
-	}
-
-	@Override
-	public void build(File target, List<String> children) throws IOException {
-		target.getParentFile().mkdirs();
-		FileWriter writer = new FileWriter(target, false);
-
-		try {
-			// concatenate references to children
-			for (String child : children) {
-				// insert child files into outputFile
-				writer.append("@import url(").append(child).append(");\n");
-			}
-
-		} finally {
-			writer.flush();
-			writer.close();
-		}
-	}
-}

File merge/merge-builder/src/main/java/org/duelengine/merge/Compactor.java

-package org.duelengine.merge;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.Map;
-
-public interface Compactor {
-
-	/**
-	 * Gets the extension which this compaction consumes
-	 * @return
-	 */
-	String[] getSourceExtensions();
-
-	/**
-	 * Gets the extension which this compaction emits
-	 * @return
-	 */
-	String getTargetExtension();
-
-	/**
-	 * Perform compaction
-	 * @param source input file
-	 * @param target output file
-	 */
-	void compact(Map<String, String> fileHashes, File source, File target) throws IOException;
-}

File merge/merge-builder/src/main/java/org/duelengine/merge/JSCompactor.java

-package org.duelengine.merge;
-
-import java.io.*;
-import java.util.*;
-import java.util.logging.Level;
-
-import com.google.javascript.jscomp.*;
-import com.google.javascript.jscomp.Compiler;
-
-class JSCompactor implements Compactor {
-
-	@Override
-	public String[] getSourceExtensions() {
-		return new String[] { ".js" };
-	}
-
-	@Override
-	public String getTargetExtension() {
-		return ".js";
-	}
-
-	@Override
-	public void compact(Map<String, String> fileHashes, File source, File target) throws IOException {
-
-		// adapted from http://blog.bolinfest.com/2009/11/calling-closure-compiler-from-java.html
-		CompilerOptions options = new CompilerOptions();
-
-		// Simple mode is used here, but additional options could be set, too.
-		CompilationLevel.SIMPLE_OPTIMIZATIONS.setOptionsForCompilationLevel(options);
-
-		// only log warnings
-		Compiler.setLoggingLevel(Level.WARNING);
-
-		Compiler compiler = new Compiler();
-
-		List<JSSourceFile> externs = CommandLineRunner.getDefaultExterns();
-		List<JSSourceFile> inputs = Collections.singletonList(JSSourceFile.fromFile(source));
-
-		// compile() returns a Result, but it is not needed here.
-		compiler.compile(externs, inputs, options);
-
-		// compiler is responsible for generating the compiled code
-		// it is not accessible via the Result
-		String result = compiler.toSource();
-
-		target.getParentFile().mkdirs();
-		FileWriter writer = new FileWriter(target, false);
-		try {
-			writer.append(result);
-		} finally {
-			writer.flush();
-			writer.close();
-		}
-	}
-}

File merge/merge-builder/src/main/java/org/duelengine/merge/JSPlaceholderGenerator.java

-package org.duelengine.merge;
-
-import java.io.File;
-import java.io.FileWriter;
-import java.io.IOException;
-import java.util.List;
-
-public class JSPlaceholderGenerator implements PlaceholderGenerator {
-
-	@Override
-	public String getTargetExtension() {
-		return ".js";
-	}
-
-	@Override
-	public void build(File target, List<String> children) throws IOException {
-		target.getParentFile().mkdirs();
-		FileWriter writer = new FileWriter(target, false);
-
-		try {
-			writer.append("(function() {\n\tvar s, d=document, f=d.getElementsByTagName('script')[0], p=f.parentNode;\n");
-
-			// concatenate references to children
-			for (String child : children) {
-				// insert child files into outputFile
-				writer
-					.append("\ts=d.createElement('script');s.type='text/javascript';s.src='")
-					.append(child.replace("'", "\\'"))
-					.append("';p.insertBefore(s,f);\n");
-			}
-
-			writer.append("})();");
-
-		} finally {
-			writer.flush();
-			writer.close();
-		}
-	}
-}

File merge/merge-builder/src/main/java/org/duelengine/merge/LinkInterceptorCssFilter.java

-package org.duelengine.merge;
-
-import java.io.IOException;
-import java.util.Map;
-import java.util.logging.Logger;
-
-import org.cssless.css.ast.*;
-import org.cssless.css.codegen.CssFilter;
-import org.cssless.css.parsing.CssLexer;
-
-public class LinkInterceptorCssFilter implements CssFilter {
-
-	private final Logger log = Logger.getLogger(LinkInterceptorCssFilter.class.getCanonicalName());
-	private final Map<String, String> linkMap;
-
-	public LinkInterceptorCssFilter(Map<String, String> linkMap) {
-		this.linkMap = linkMap;
-	}
-	
-	@Override
-	public CssNode filter(CssNode node) {
-		if (node.getNodeType() != CssNodeType.FUNCTION) {
-			return node;
-		}
-
-		FunctionNode func = (FunctionNode)node;
-		if (!"url".equals(func.getValue())) {
-			return node;
-		}
-
-		ContainerNode children = func.getContainer();
-
-		if (children.childCount() > 1) {
-			// HACK: we need a consolidated value rather than expression
-			StringBuilder buffer = new StringBuilder();
-			try {
-				new org.cssless.css.codegen.CssFormatter().writeNode(buffer, children, null);
-			} catch (IOException e) {
-				return node;
-			}
-			String value = buffer.toString();
-			if (value == null || value.isEmpty()) {
-				return node;
-			}
-			children.getChildren().clear();
-			children.getChildren().add(new ValueNode(value));
-		}
-
-		// should be only one child
-		for (CssNode child : children.getChildren()) {
-			if (child instanceof ValueNode) {
-				ValueNode valNode = ((ValueNode)child);
-				String val = valNode.getValue();
-				val = CssLexer.decodeString(val);
-				if (val == null || val.isEmpty()) {
-					break;
-				}
-
-				String valHash = this.linkMap.get(val);
-				if (valHash == null) {
-					log.warning("Missing CSS reference: "+val);
-					break;
-				}
-
-				// trim path to just filename
-				// this will make URL relative from stylesheet
-				valHash = valHash.substring(valHash.lastIndexOf('/')+1);
-
-				if (child instanceof StringNode) {
-					valHash = CssLexer.encodeString(valHash);
-				}
-				valNode.setValue(valHash);
-				log.info("CSS url: "+val+" => "+valHash);
-
-			} else {
-				log.warning("Unexpected CSS url type: "+child.getNodeType());
-			}
-		}
-		
-		return node;
-	}
-}

File merge/merge-builder/src/main/java/org/duelengine/merge/MergeBuilder.java

-package org.duelengine.merge;
-
-import java.io.*;
-import java.security.*;
-import java.util.*;
-import java.util.logging.Logger;
-
-public class MergeBuilder {
-
-	private static final String HELP =
-		"Usage:\n" +
-		"\tjava -jar merge.jar <webapp-dir>\n" +
-		"\tjava -jar merge.jar <webapp-dir> <cdn-output-path>\n" +
-		"\tjava -jar merge.jar <webapp-dir> <cdn-output-path> <cdn-map-file>\n\n"+
-		"\twebapp-dir: file path to the root of the webapp\n"+
-		"\tcdn-output-path: webapp-relative path for the cdn output\n"+
-		"\tcdn-map-file: path to the generated resource file\n";
-
-	private static final int BUFFER_SIZE = 4096;
-	private static final String HASH_ALGORITHM = "SHA-1";
-	private static final String CHAR_ENCODING = "utf-8";
-
-	public static void main(String[] args) {
-		if (args.length < 1) {
-			System.out.println(HELP);
-			return;
-		}
-
-		MergeBuilder builder = new MergeBuilder();
-		builder.setWebAppDir(args[0]);
-
-		if (args.length > 1) {
-			builder.setCDNRoot(args[1]);
-
-			if (args.length > 2) {
-				builder.setCDNMapFile(args[2]);
-			}
-		}
-
-		try {
-			builder.execute();
-
-		} catch (Exception e) {
-			e.printStackTrace(System.err);
-		}
-	}
-
-	private final Logger log = Logger.getLogger(MergeBuilder.class.getCanonicalName());
-	private final Map<String, Compactor> compactors;
-	private final Map<String, PlaceholderGenerator> placeholders;
-	private File webappDir;
-	private String cdnRoot;
-	private File cdnMapFile;
-
-	public MergeBuilder(String... cdnExtensions) {
-		this(Arrays.asList(
-				new JSPlaceholderGenerator(),
-				new CSSPlaceholderGenerator()),
-			Arrays.asList(
-				new NullCompactor(cdnExtensions),
-				new CSSCompactor(),
-				new JSCompactor()));
-	}
-
-	public MergeBuilder(List<PlaceholderGenerator> placeholders, List<Compactor> compactors) {
-		if (placeholders == null) {
-			throw new NullPointerException("placeholders");
-		}
-		if (compactors == null) {
-			throw new NullPointerException("compactors");
-		}
-
-		this.placeholders = new LinkedHashMap<String, PlaceholderGenerator>(placeholders.size());
-		if (placeholders != null) {
-			for (PlaceholderGenerator placeholder : placeholders) {
-				this.placeholders.put(placeholder.getTargetExtension(), placeholder);
-			}
-		}
-
-		this.compactors = new LinkedHashMap<String, Compactor>(compactors.size());
-		for (Compactor compactor : compactors) {
-			for (String ext : compactor.getSourceExtensions()) {
-				this.compactors.put(ext, compactor);
-			}
-		}
-	}
-
-	public String getWebAppDir() {
-		return this.webappDir.getAbsolutePath();
-	}
-
-	public void setWebAppDir(String value) {
-		this.webappDir = (value != null) ? new File(value.replace('\\', '/')) : null;
-	}
-
-	public String getCDNRoot() {
-		return this.cdnRoot;
-	}
-
-	public void setCDNRoot(String value) {
-		if (value != null) {
-			value = value.replace('\\', '/');
-			if (!value.startsWith("/")) {
-				value = '/'+value;
-			}
-			if (!value.endsWith("/")) {
-				value += '/';
-			}
-		}
-		this.cdnRoot = value;
-	}
-
-	public String getCDNMapFile() {
-		return this.cdnMapFile.getAbsolutePath();
-	}
-
-	public void setCDNMapFile(String value) {
-		this.cdnMapFile = (value != null) ? new File(value.replace('\\', '/')) : null;
-	}
-
-	private boolean ensureSettings() {
-		if (this.webappDir == null || !this.webappDir.exists()) {
-			throw new IllegalArgumentException("Error: missing webapp "+this.webappDir);
-		}
-
-		if (this.cdnRoot == null) {
-			this.cdnRoot = "/cdn/";
-		}
-
-		if (this.cdnMapFile == null) {
-			this.cdnMapFile = new File(this.webappDir.getParentFile(), "resources/cdn.properties");
-		}
-
-		return true;
-	}
-
-	/**
-	 * Compiles merge files
-	 * @throws IOException 
-	 * @throws NoSuchAlgorithmException 
-	 */
-	public void execute() throws IOException, NoSuchAlgorithmException {
-		if (!this.ensureSettings()) {
-			return;
-		}
-
-		final Map<String, String> hashLookup = new LinkedHashMap<String, String>();
-
-		// calculate hash and compact all the source files
-		for (String ext : this.compactors.keySet()) {
-			hashClientFiles(hashLookup, ext);
-		}
-
-		if (hashLookup.size() < 1) {
-			throw new IllegalArgumentException("Error: no input files found in "+this.webappDir);
-		}
-
-		// calculate hash for all the merge files and determine dependencies
-		Map<String, List<String>> dependencyMap = this.hashMergeFiles(hashLookup);
-
-		for (String path : dependencyMap.keySet()) {
-			List<String> children = dependencyMap.get(path);
-
-			this.buildMerge(hashLookup, path, children);
-			this.buildDevPlaceholders(hashLookup, path, children);
-		}
-
-		saveHashLookup(hashLookup);
-	}
-
-	private void buildMerge(final Map<String, String> hashLookup, String path, List<String> children)
-		throws FileNotFoundException, IOException {
-
-		log.info("Building "+path);
-		String outputPath = hashLookup.get(path);
-
-		File outputFile = new File(this.webappDir, outputPath);
-		if (outputFile.exists()) {
-			log.info("- exists: "+outputPath);
-			return;
-		}
-		log.info("- writing to "+outputPath);
-
-		outputFile.getParentFile().mkdirs();
-		FileWriter writer = new FileWriter(outputFile, false);
-
-		try {
-			// concatenate children
-			final char[] buffer = new char[BUFFER_SIZE];
-			for (String child : children) {
-				// insert child files into outputFile
-				log.info("- adding "+child);
-				File inputFile = new File(this.webappDir, hashLookup.get(child));
-				FileReader reader = new FileReader(inputFile);
-				try {
-					int count;
-					while ((count = reader.read(buffer)) > 0) {
-						writer.write(buffer, 0, count);
-					}
-				} finally {
-					reader.close();
-				}
-			}
-
-		} finally {
-			writer.flush();
-			writer.close();
-		}
-	}
-
-	private void buildDevPlaceholders(final Map<String, String> hashLookup, String path, List<String> children)
-		throws FileNotFoundException, IOException {
-
-		String hashPath = hashLookup.get(path);
-		int slash = hashPath.lastIndexOf('/');
-
-		// insert dev dir
-		String devPath = hashPath.substring(0, slash)+"/dev"+hashPath.substring(slash);
-		hashLookup.put(hashPath, devPath);
-
-		File outputFile = new File(this.webappDir, devPath);
-		if (outputFile.exists()) {
-			return;
-		}
-
-		PlaceholderGenerator generator = this.placeholders.get(getExtension(hashPath));
-		if (generator == null) {
-			log.warning("Cannot generate placeholder for "+hashPath);
-			return;
-		}
-
-		generator.build(outputFile, children);
-	}
-
-	private Map<String, List<String>> hashMergeFiles(final Map<String, String> hashLookup)
-			throws IOException, NoSuchAlgorithmException {
-
-		final int rootPrefix = this.webappDir.getCanonicalPath().length();
-		final List<File> inputFiles = findFiles(this.webappDir, ".merge", this.cdnRoot);
-		final Map<String, List<String>> dependencyMap = new LinkedHashMap<String, List<String>>(inputFiles.size());
-
-		for (File inputFile : inputFiles) {
-			List<String> children = new ArrayList<String>();
-
-			String hashPath = this.cdnRoot+calcMergeHash(inputFile, children, hashLookup);
-
-			// merge file takes the first non-empty extension
-			String ext = null;
-			for (String child : children) {
-				ext = getExtension(child);
-				if (ext != null) {
-					break;
-				}
-			}
-			if (ext == null) {
-				ext = ".merge";
-			}
-			hashPath += ext;
-
-			String path = inputFile.getCanonicalPath().substring(rootPrefix);
-			hashLookup.put(path, hashPath);
-			dependencyMap.put(path, children);
-		}
-
-		return dependencyMap;
-	}
-
-	private void hashClientFiles(final Map<String, Str