Commits

Stephen McKamey committed 1ae043f

enabling recursive merge dependencies; improved speration of concerns; improved CLI

Comments (0)

Files changed (12)

merge-builder/pom.xml

 	<properties>
 		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 
-		<cssless.version>0.4.0</cssless.version>
-		<closure.version>r1459</closure.version>
+		<css.version>0.4.0</css.version>
+		<closure.version>r1592</closure.version>
 		<slf4j.version>1.6.4</slf4j.version>
 		<junit.version>4.8.2</junit.version>
 		<jvm.version>1.6</jvm.version>
 		<dependency>
 			<groupId>org.cssless</groupId>
 			<artifactId>css</artifactId>
-			<version>${cssless.version}</version>
+			<version>${css.version}</version>
 		</dependency>
 
 		<dependency>

merge-builder/src/main/java/org/duelengine/merge/BuildManager.java

+package org.duelengine.merge;
+
+import java.io.*;
+import java.security.*;
+import java.util.*;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class BuildManager {
+
+	private static final String HASH_ALGORITHM = "SHA-1";
+	private static final String NEWLINE = System.getProperty("line.separator");
+	private static final Logger log = LoggerFactory.getLogger(BuildManager.class);
+
+	private final Map<String, String> hashLookup = new LinkedHashMap<String, String>();
+	private final Map<String, List<String>> dependencyMap = new LinkedHashMap<String, List<String>>();
+	private final Map<String, Compactor> compactors;
+	private final Settings settings;
+	private final Stack<String> dependencyChain = new Stack<String>();
+
+	/**
+	 * @param settings path location settings
+	 */
+	public BuildManager(Settings settings) {
+		this(settings,
+			new MergeCompactor(
+				new JSPlaceholderGenerator(),
+				new CSSPlaceholderGenerator()),
+			new NullCompactor(settings.getExtensions()),
+			new CSSCompactor(),
+			new JSCompactor());
+	}
+
+	/**
+	 * @param settings path location settings
+	 * @param compactors list of all active compactors
+	 */
+	public BuildManager(Settings settings, Compactor... compactors) {
+		if (settings == null) {
+			throw new NullPointerException("settings");
+		}
+		if (compactors == null) {
+			throw new NullPointerException("compactors");
+		}
+		if (settings.getSourceDir() == null || !settings.getSourceDir().exists()) {
+			throw new IllegalArgumentException("Missing source directory "+settings.getSourceDir());
+		}
+
+		this.settings = settings;
+
+		this.compactors = new LinkedHashMap<String, Compactor>(compactors.length);
+		for (Compactor compactor : compactors) {
+			for (String ext : compactor.getSourceExtensions()) {
+				this.compactors.put(ext, compactor);
+			}
+		}
+	}
+
+	/**
+	 * Compiles merge files and processes resources
+	 * @throws IOException 
+	 * @throws NoSuchAlgorithmException 
+	 */
+	public void execute()
+			throws IOException, NoSuchAlgorithmException {
+
+		Map<File, String> inputFiles = findFiles();
+
+		for (File source : inputFiles.keySet()) {
+			processResource(
+				inputFiles.get(source),
+				source);
+		}
+
+		writeCompactionMap();
+	}
+
+	private boolean isHashCalculated(String path) {
+		return hashLookup.containsKey(path);
+	}
+
+	public String getProcessedPath(String path) {
+		return hashLookup.get(path);
+	}
+
+	public void setProcessedPath(String path, String hashPath) {
+		hashLookup.put(path, hashPath);
+	}
+
+	private void removeProcessedPath(String path) {
+		hashLookup.remove(path);
+	}
+
+	public void ensureProcessed(String path) {
+
+		if (isHashCalculated(path) && getTargetFile(path).exists()) {
+			return;
+		}
+
+		try {
+			processResource(path, settings.findSourceFile(path));
+
+		} catch (NoSuchAlgorithmException e) {
+			log.error(e.getMessage(), e);
+
+		} catch (IOException e) {
+			log.error(e.getMessage(), e);
+		}
+	}
+	
+	private void processResource(String path, File source)
+			throws IOException, NoSuchAlgorithmException {
+
+		// keep track of currently compacting paths to prevent cycles
+		if (dependencyChain.contains(path)) {
+			log.error("Cyclical dependencies detected in: "+path);
+			return;
+		}
+		dependencyChain.push(path);
+
+		try {
+			String sourceExt = getExtension( source.getCanonicalPath() );
+			Compactor compactor = compactors.get(sourceExt);
+			if (compactor == null) {
+				log.error("No compactor registered for "+sourceExt);
+				return;
+			}
+	
+			if (!isHashCalculated(path)) {
+				MessageDigest hash = MessageDigest.getInstance(HASH_ALGORITHM);
+				compactor.calcHash(this, hash, path, source);
+				String hashPath = encodeBytes(hash.digest());
+				String targetExt = compactor.getTargetExtension(this, path);
+				setProcessedPath(path, settings.getCDNRoot()+hashPath+targetExt);
+			}
+	
+			File target = getTargetFile(path);
+			if (!target.exists()) {
+				// ensure target path exists
+				target.getParentFile().mkdirs();
+	
+				// ensure the file has been compacted
+				compactor.compact(this, path, source, target);
+			}
+	
+			if (!target.exists()) {
+				// file still missing, remove
+				log.error(path+" failed to compact (output missing)");
+				removeProcessedPath(path);
+	
+			} else if (target.length() < 1L) {
+				if (source.length() < 1L) {
+					// special case for files which compact to empty
+					log.warn(path+" is an empty file");
+	
+					// remove from listings
+					removeProcessedPath(path);
+	
+				} else {
+					// special case for files which compact to empty
+					log.warn(path+" compacted to an empty file (using original for merge)");
+	
+					// copy over original contents (as wasn't actually empty)
+					new NullCompactor().compact(this, path, source, target);
+				}
+			}
+
+		} finally {
+			dependencyChain.pop();
+		}
+	}
+
+	public void addDependency(String path, String child) {
+		List<String> children = dependencyMap.get(path);
+		if (children == null) {
+			children = new ArrayList<String>();
+			dependencyMap.put(path, children);
+		}
+		children.add(child);
+	}
+
+	public List<String> getDependencies(String path) {
+		List<String> dependencies = dependencyMap.get(path);
+		if (dependencies == null) {
+			dependencies = Collections.emptyList();
+		}
+		return dependencies;
+	}
+
+	public static String getExtension(String path) {
+		int dot = path.lastIndexOf('.');
+		if (dot < 0) {
+			return "";
+		}
+
+		return path.substring(dot).toLowerCase();
+	}
+
+	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();
+	}
+
+	public File getTargetFile(String path) {
+		String outputPath = getProcessedPath(path);
+
+		return settings.getTargetFile(outputPath);
+	}
+
+	private Map<File, String> findFiles()
+			throws IOException {
+
+		Set<String> extensions = getExtensions();
+		String filterPath = settings.getCDNDir().getCanonicalPath();
+		Queue<File> folders = new LinkedList<File>();
+		Map<File, String> files = new LinkedHashMap<File, String>();
+
+		for (File inputDir : new File[] { settings.getSourceDir(), settings.getTargetDir() }) {
+			int rootPrefix = inputDir.getCanonicalPath().length();
+
+			folders.add(inputDir);
+			while (!folders.isEmpty()) {
+				File file = folders.remove();
+
+				if (file.getCanonicalPath().startsWith(filterPath)) {
+					// filter any output files, e.g., if dirs overlap
+					continue;
+				}
+
+				if (file.isDirectory()) {
+					folders.addAll(Arrays.asList(file.listFiles()));
+					continue;
+				}
+
+				String ext = BuildManager.getExtension(file.getCanonicalPath());
+				if (extensions.contains(ext)) {
+					files.put(file, file.getCanonicalPath().substring(rootPrefix));
+				}
+			}
+		}
+
+		return files;
+	}
+
+	private Set<String> getExtensions() {
+		return this.compactors.keySet();
+	}
+
+	private void writeCompactionMap()
+			throws IOException {
+
+		File cdnMapFile = settings.getCDNMapFile();
+
+		cdnMapFile.getParentFile().mkdirs();
+
+		FileWriter writer = new FileWriter(cdnMapFile, false);
+		try {
+			writeCompactionMap(writer);
+
+		} finally {
+			writer.flush();
+			writer.close();
+		}
+	}
+	
+	private void writeCompactionMap(Appendable output)
+			throws IOException {
+
+		// generate output
+		for (String key : hashLookup.keySet()) {
+			String value = hashLookup.get(key);
+			value = escapePropertyValue(value);
+
+			output
+				.append(key)
+				.append('=')
+				.append(value)
+				.append(NEWLINE);
+		}
+	}
+
+	/**
+	 * http://download.oracle.com/javase/6/docs/api/java/util/Properties.html#load(java.io.Reader)
+	 * @param value
+	 * @return
+	 */
+	private static String escapePropertyValue(String value) {
+		if (value == null) {
+			return "";
+		}
+
+		StringBuilder output = null;
+		int start = 0,
+			length = value.length();
+
+		for (int i=start; i<length; i++) {
+			char ch = value.charAt(i);
+
+			// escape any illegal chars [:=#!\s]+
+			switch (ch) {
+				case ':':
+				case '=':
+				case '#':
+				case '!':
+				case '\t':
+				case '\n':
+				case '\r':
+				case ' ':
+					if (output == null) {
+						output = new StringBuilder(length * 2);
+					}
+
+					if (i > start) {
+						// emit any leading unescaped chunk
+						output.append(value, start, i);
+					}
+					start = i+1;
+
+					// emit escape
+					output.append('\\').append(ch);
+					continue;
+			}
+		}
+
+		if (output == null) {
+			// nothing to escape, can write entire string directly
+			return value;
+		}
+
+		if (length > start) {
+			// emit any trailing unescaped chunk
+			output.append(value, start, length);
+		}
+
+		return output.toString();
+	}
+}

merge-builder/src/main/java/org/duelengine/merge/CLI.java

 
 public class CLI {
 
-	private static final String HELP =
-		"Usage:\n" +
-		"\tjava -jar merge-builder.jar <webapp-directory> <cdn-map-file>\n"+
-		"\tjava -jar merge-builder.jar <webapp-directory> <cdn-map-file> <output-directory>\n"+
-		"\tjava -jar merge-builder.jar <webapp-directory> <cdn-map-file> <output-directory> <cdn-url-root>\n\n"+
-		"\twebapp-directory: file path to the root of the webapp (required)\n"+
-		"\tcdn-map-file: path of the generated map resource file (required)\n"+
-		"\toutput-directory: file path to the root of the output (default: <webapp-directory>)\n"+
-		"\tcdn-url-root: relative URL path for the cdn output root (default: \"/cdn/\")\n";
+	private static final String SEPARATOR = "========================================";
+	private static final String HELP = "java -jar merge-builder.jar\n"+
+			"  --help               : this help text\n"+
+			"  -in <source-dir>     : file path to the root of the webapp (required)\n"+
+			"  -out <target-dir>    : file path to the root of the build output (default: <source-dir>)\n"+
+			"  -map <cdn-map-file>  : path of the generated map resource file\n" +
+			"                         (default: \"<target-dir>/cdn.properties\")\n"+
+			"  -cdn <cdn-root-path> : relative URL path for the cdn output root (default: \"/cdn/\")\n"+
+			"  -ext <file-ext-list> : quoted list of file extensions to add to CDN (default: none)\n" +
+			"                         (example: \".png .jpg .gif .ico .woff .ttf .eot .svg\")\n";
 
 	public static void main(String[] args) {
-		if (args.length < 3) {
+		if (args.length < 1) {
 			System.out.println(HELP);
 			return;
 		}
 
-		MergeBuilder builder = new MergeBuilder();
-		builder.setWebAppDir(args[0]);
-		builder.setCDNMapFile(args[2]);
+		Settings settings = new Settings();
+		System.out.println(SEPARATOR);
+		System.out.println("Merge Builder\n");
+		for (int i=0; i<args.length; i++) {
+			String arg = args[i];
 
-		if (args.length > 2) {
-			builder.setOutputDir(args[3]);
+			if ("-in".equals(arg)) {
+				settings.setSourceDir(args[++i]);
 
-			if (args.length > 3) {
-				builder.setCDNRoot(args[1]);
+			} else if ("-out".equals(arg)) {
+				settings.setTargetDir(args[++i]);
+
+			} else if ("-map".equals(arg)) {
+				settings.setCDNMapFile(args[++i]);
+
+			} else if ("-cdn".equals(arg)) {
+				settings.setCDNRoot(args[++i]);
+
+			} else if ("-ext".equals(arg)) {
+				settings.setExtensionList(args[++i]);
+
+			} else if ("--help".equalsIgnoreCase(arg)) {
+				System.out.println(HELP);
+				System.out.println(SEPARATOR);
+				return;
+
+			} else {
+				System.out.println(HELP);
+				System.out.println(SEPARATOR);
+				return;
 			}
 		}
 
 		try {
-			builder.execute();
+			new BuildManager(settings).execute();
 
-		} catch (Exception e) {
-			e.printStackTrace(System.err);
+		} catch (Exception ex) {
+			ex.printStackTrace(System.err);
 		}
 	}
 }

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

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

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

 
 import java.io.File;
 import java.io.IOException;
-import java.util.Map;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 
 public interface Compactor {
 
 	 * Gets the extension which this compaction emits
 	 * @return
 	 */
-	String getTargetExtension();
+	String getTargetExtension(BuildManager manager, String path);
+
+	/**
+	 * Generate SHA-1 hash for the specified file
+	 * @param manager
+	 * @param hash
+	 * @param path
+	 * @param source
+	 * @throws IOException
+	 * @throws NoSuchAlgorithmException
+	 */
+	void calcHash(BuildManager manager, MessageDigest hash, String path, File source)
+			throws IOException, NoSuchAlgorithmException;
 
 	/**
 	 * Perform compaction
+	 * @param manager
+	 * @param path URL path
 	 * @param source input file
 	 * @param target output file
-	 * @param path URL path
 	 */
-	void compact(Map<String, String> fileHashes, File source, File target, String path) throws IOException;
+	void compact(BuildManager manager, String path, File source, File target)
+			throws IOException;
 }

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

 import com.google.javascript.jscomp.*;
 import com.google.javascript.jscomp.Compiler;
 
-class JSCompactor implements Compactor {
+class JSCompactor extends NullCompactor {
 
-	@Override
-	public String[] getSourceExtensions() {
-		return new String[] { ".js" };
+	public JSCompactor() {
+		super(".js");
 	}
 
 	@Override
-	public String getTargetExtension() {
-		return ".js";
-	}
-
-	@Override
-	public void compact(Map<String, String> fileHashes, File source, File target, String path) throws IOException {
+	public void compact(BuildManager manager, String path, File source, File target)
+			throws IOException {
 
 		// adapted from http://blog.bolinfest.com/2009/11/calling-closure-compiler-from-java.html
 		CompilerOptions options = new CompilerOptions();
 		// 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 {
+			// compiler is responsible for generating the compiled code
+			// it is not accessible via the Result
+			String result = compiler.toSource();
 			writer.append(result);
+
 		} finally {
 			writer.flush();
 			writer.close();

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

 
 import java.io.IOException;
 import java.net.URI;
-import java.util.Map;
 
 import org.cssless.css.ast.*;
 import org.cssless.css.codegen.CssFilter;
 public class LinkInterceptorCssFilter implements CssFilter {
 
 	private final Logger log = LoggerFactory.getLogger(LinkInterceptorCssFilter.class);
-	private final Map<String, String> linkMap;
+	private final BuildManager manager;
 	private final URI context;
 
-	public LinkInterceptorCssFilter(Map<String, String> linkMap, URI context) {
-		this.linkMap = linkMap;
+	public LinkInterceptorCssFilter(BuildManager manager, URI context) {
+		this.manager = manager;
 		this.context = context;
 	}
 	
 					}
 				}
 
-				String valHash = this.linkMap.get(val);
+				manager.ensureProcessed(val);
+
+				String valHash = manager.getProcessedPath(val);
 				if (valHash == null) {
 					log.warn("Missing CSS reference: "+val);
 					break;

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

-package org.duelengine.merge;
-
-import java.io.*;
-import java.security.*;
-import java.util.*;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class MergeBuilder {
-
-	private static final int BUFFER_SIZE = 4096;
-	private static final String HASH_ALGORITHM = "SHA-1";
-	private static final String CHAR_ENCODING = "utf-8";
-
-	private final Logger log = LoggerFactory.getLogger(MergeBuilder.class);
-	private final Map<String, Compactor> compactors;
-	private final Map<String, PlaceholderGenerator> placeholders;
-	private File webappDir;
-	private File outputDir;
-	private File cdnMapFile;
-	private String cdnRoot;
-
-	/**
-	 * @param cdnExtensions extensions which will be directly copied without processing
-	 */
-	public MergeBuilder(String... cdnExtensions) {
-		this(Arrays.asList(
-				new JSPlaceholderGenerator(),
-				new CSSPlaceholderGenerator()),
-			Arrays.asList(
-				new NullCompactor(cdnExtensions),
-				new CSSCompactor(),
-				new JSCompactor()));
-	}
-
-	/**
-	 * @param placeholders list of debug build placeholder generators
-	 * @param compactors list of all active compactors
-	 */
-	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());
-		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() {
-		try {
-			return (this.webappDir != null) ? this.webappDir.getCanonicalPath() : null;
-		} catch (IOException e) {
-			return (this.webappDir != null) ? this.webappDir.getAbsolutePath() : null;
-		}
-	}
-
-	public void setWebAppDir(String value) {
-		this.webappDir = (value != null) ? new File(value.replace('\\', '/')) : null;
-	}
-
-	public String getOutputDir() {
-		try {
-			return (this.outputDir != null) ? this.outputDir.getCanonicalPath() : null;
-		} catch (IOException e) {
-			return (this.outputDir != null) ? this.outputDir.getAbsolutePath() : null;
-		}
-	}
-
-	public void setOutputDir(String value) {
-		this.outputDir = (value != null) ? new File(value.replace('\\', '/')) : null;
-	}
-
-	public String getCDNMapFile() {
-		try {
-			return (this.cdnMapFile != null) ? this.cdnMapFile.getCanonicalPath() : null;
-		} catch (IOException e) {
-			return (this.cdnMapFile != null) ? this.cdnMapFile.getAbsolutePath() : null;
-		}
-	}
-
-	public void setCDNMapFile(String value) {
-		this.cdnMapFile = (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;
-	}
-
-	private File getCDNDir() {
-		return new File(getOutputDir(), this.cdnRoot);
-	}
-
-	private void ensureSettings() {
-		if (this.webappDir == null || !this.webappDir.exists()) {
-			throw new IllegalArgumentException("ERROR: missing webappDir "+this.webappDir);
-		}
-		if (this.cdnMapFile == null) {
-			throw new IllegalArgumentException("ERROR: missing cdnMapFile");
-		}
-
-		if (this.cdnRoot == null) {
-			this.cdnRoot = "/cdn/";
-		}
-		if (this.outputDir == null) {
-			this.outputDir = this.webappDir;
-		}
-	}
-
-	/**
-	 * Compiles merge files and processes resources
-	 * @throws IOException 
-	 * @throws NoSuchAlgorithmException 
-	 */
-	public void execute() throws IOException, NoSuchAlgorithmException {
-		this.ensureSettings();
-
-		final Map<String, String> hashLookup = new LinkedHashMap<String, String>();
-
-		// calculate hash and compact all the source files
-		hashClientFiles(hashLookup, this.compactors.keySet());
-
-		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.buildDebugPlaceholders(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.outputDir, 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.outputDir, 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 buildDebugPlaceholders(final Map<String, String> hashLookup, String path, List<String> children)
-		throws FileNotFoundException, IOException {
-
-		String hashPath = hashLookup.get(path);
-		if (children.size() == 1) {
-			// if only one child then the source file is the debugPath
-			hashLookup.put(hashPath, children.get(0));
-			return;
-		}
-
-		// splice in the debug directory
-		int slash = hashPath.lastIndexOf('/');
-		String debugPath = hashPath.substring(0, slash)+"/debug"+hashPath.substring(slash);
-		hashLookup.put(hashPath, debugPath);
-
-		File outputFile = new File(this.outputDir, debugPath);
-		if (outputFile.exists()) {
-			return;
-		}
-
-		PlaceholderGenerator generator = this.placeholders.get(getExtension(hashPath));
-		if (generator == null) {
-			log.warn("Cannot generate debug placeholder for "+debugPath);
-			return;
-		}
-
-		generator.build(outputFile, children);
-	}
-
-	private Map<String, List<String>> hashMergeFiles(Map<String, String> hashLookup)
-			throws IOException, NoSuchAlgorithmException {
-
-		Map<File, String> inputFiles = findFiles(Collections.singleton(".merge"), this.getCDNDir(), this.webappDir, this.outputDir);
-		Map<String, List<String>> dependencyMap = new LinkedHashMap<String, List<String>>(inputFiles.size());
-
-		for (File inputFile : inputFiles.keySet()) {
-			List<String> children = new ArrayList<String>();
-
-			String path = inputFiles.get(inputFile);
-			if (hashLookup.containsKey(path)) {
-				// duplicate from output
-				continue;
-			}
-			String hashPath = this.cdnRoot+calcMergeHash(inputFile, children, hashLookup);
-
-			// merge file assumes the first non-empty extension
-			String ext = null;
-			for (String child : children) {
-				ext = getExtension(child);
-				if (ext != null) {
-					break;
-				}
-			}
-
-			hashPath += (ext == null) ? ".merge" : ext;
-
-			hashLookup.put(path, hashPath);
-			dependencyMap.put(path, children);
-		}
-
-		return dependencyMap;
-	}
-
-	private void hashClientFiles(Map<String, String> hashLookup, Set<String> extensions)
-			throws IOException, NoSuchAlgorithmException {
-
-		Map<File, String> inputFiles = findFiles(extensions, this.getCDNDir(), this.webappDir, this.outputDir);
-
-		for (File inputFile : inputFiles.keySet()) {
-
-			// calculate and store the hash
-			String path = inputFiles.get(inputFile);
-			if (hashLookup.containsKey(path)) {
-				// already processed
-				continue;
-			}
-
-			hashClientFile(hashLookup, inputFile, path);
-		}
-	}
-
-	private void hashClientFile(Map<String, String> hashLookup, File inputFile, String path)
-			throws IOException, NoSuchAlgorithmException {
-
-		String ext = getExtension( inputFile.getCanonicalPath() );
-		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;
-		}
-
-		// ensure the file has been compacted
-		String hashPath = this.cdnRoot + this.calcFileHash(inputFile) + targetExt;
-		File outputFile = new File(this.outputDir, hashPath);
-		if (!outputFile.exists()) {
-			// ensure compacted target path exists
-			compactor.compact(hashLookup, inputFile, outputFile, path);
-		}
-		hashLookup.put(path, hashPath);
-
-		if (!outputFile.exists()) {
-			// file still missing, remove
-			log.error(path+" failed to compact (output missing)");
-			hashLookup.remove(path);
-
-		} else if (outputFile.length() < 1L) {
-			if (inputFile.length() < 1L) {
-				// special case for files which compact to empty
-				log.warn(path+" is an empty file");
-
-				// remove from listings
-				hashLookup.remove(path);
-			} else {
-				// special case for files which compact to empty
-				log.warn(path+" compacted to an empty file (using original)");
-
-				// copy over original contents (as wasn't really empty)
-				new NullCompactor().compact(hashLookup, inputFile, outputFile, 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()) {
-				writer.append(key).append('=').append(escapePropertyValue(hashLookup.get(key))).append(newline);
-			}
-
-		} finally {
-			writer.flush();
-			writer.close();
-		}
-	}
-
-	/**
-	 * http://download.oracle.com/javase/6/docs/api/java/util/Properties.html#load(java.io.Reader)
-	 * @param value
-	 * @return
-	 */
-	private CharSequence escapePropertyValue(String value) {
-
-		if (value == null) {
-			return "";
-		}
-
-		StringBuilder output = null;
-		int start = 0,
-			length = value.length();
-
-		for (int i=start; i<length; i++) {
-			char ch = value.charAt(i);
-
-			// escape any illegal chars [:=#!\s]+
-			switch (ch) {
-				case ':':
-				case '=':
-				case '#':
-				case '!':
-				case '\t':
-				case '\n':
-				case '\r':
-				case ' ':
-					if (output == null) {
-						output = new StringBuilder(length * 2);
-					}
-
-					if (i > start) {
-						// emit any leading unescaped chunk
-						output.append(value, start, i);
-					}
-					start = i+1;
-
-					// emit escape
-					output.append('\\').append(ch);
-					continue;
-			}
-		}
-
-		if (output == null) {
-			// nothing to escape, can write entire string directly
-			return value;
-		}
-
-		if (length > start) {
-			// emit any trailing unescaped chunk
-			output.append(value, start, length);
-		}
-
-		return output.toString();
-	}
-
-	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.warn("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).toLowerCase();
-	}
-
-	private static Map<File, String> findFiles(Set<String> extensions, File filterDir, File... inputDirs)
-		throws IOException {
-
-		String filterPath = filterDir.getCanonicalPath();
-		Queue<File> folders = new LinkedList<File>();
-		Map<File, String> files = new LinkedHashMap<File, String>();
-
-		for (File inputDir : inputDirs) {
-			int rootPrefix = inputDir.getCanonicalPath().length();
-
-			folders.add(inputDir);
-			while (!folders.isEmpty()) {
-				File file = folders.remove();
-
-				if (file.getCanonicalPath().startsWith(filterPath)) {
-					// filter any output files if dirs overlap
-					continue;
-				}
-
-				if (file.isDirectory()) {
-					folders.addAll(Arrays.asList(file.listFiles()));
-					continue;
-				}
-
-				String ext = getExtension(file.getCanonicalPath());
-				if (extensions.contains(ext)) {
-					files.put(file, file.getCanonicalPath().substring(rootPrefix));
-				}
-			}
-		}
-
-		return files;
-	}
-}

merge-builder/src/main/java/org/duelengine/merge/MergeCompactor.java

+package org.duelengine.merge;
+
+import java.io.*;
+import java.security.*;
+import java.util.*;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class MergeCompactor implements Compactor {
+
+	private static final int BUFFER_SIZE = 4096;
+	private static final String CHAR_ENCODING = "utf-8";
+	private final Logger log = LoggerFactory.getLogger(MergeCompactor.class);
+	private final Map<String, PlaceholderGenerator> placeholders;
+
+	public MergeCompactor(PlaceholderGenerator... placeholders) {
+		if (placeholders == null) {
+			throw new NullPointerException("placeholders");
+		}
+
+		this.placeholders = new LinkedHashMap<String, PlaceholderGenerator>(placeholders.length);
+		for (PlaceholderGenerator placeholder : placeholders) {
+			this.placeholders.put(placeholder.getTargetExtension(), placeholder);
+		}
+	}
+	
+	@Override
+	public String[] getSourceExtensions() {
+		return new String[] { ".merge" };
+	}
+
+	@Override
+	public String getTargetExtension(BuildManager manager, String path) {
+		// merge file assumes the first non-empty extension
+		for (String child : manager.getDependencies(path)) {
+			String ext = BuildManager.getExtension(child);
+			if (!ext.isEmpty()) {
+				return ext;
+			}
+		}
+
+		return "";
+	}
+
+	@Override
+	public void calcHash(BuildManager manager, MessageDigest hash, String path, File source)
+			throws IOException, NoSuchAlgorithmException {
+
+		FileReader reader = new FileReader(source);
+		try {
+			BufferedReader lineReader = new BufferedReader(reader);
+
+			// calculate the hash for the merge file as a hash of the dependency hash paths
+			// if any of the dependencies change this hash will also
+
+			String dependency;
+			while ((dependency = lineReader.readLine()) != null) {
+				dependency = dependency.trim();
+				if (dependency.isEmpty() || dependency.startsWith("#")) {
+					// skip empty lines and comments
+					continue;
+				}
+
+				manager.ensureProcessed(dependency);
+
+				String dependencyPath = manager.getProcessedPath(dependency);
+				if (dependencyPath == null) {
+					// skip missing resources (will be reflected in hash when come available)
+					log.warn("Missing merge reference: "+dependency);
+					continue;
+				}
+
+				manager.addDependency(path, dependency);
+				hash.update(dependencyPath.getBytes(CHAR_ENCODING));
+			}
+
+		} finally {
+			reader.close();
+		}
+	}
+
+	@Override
+	public void compact(BuildManager manager, String path, File source, File target)
+			throws IOException {
+
+		this.buildMerge(manager, path, target);
+		this.buildDebugPlaceholders(manager, path);
+	}
+
+	private void buildMerge(BuildManager manager, String path, File target)
+		throws FileNotFoundException, IOException {
+		
+		log.info("Building "+path);
+		String outputPath = manager.getProcessedPath(path);
+
+		if (target.exists()) {
+			log.info("- exists: "+outputPath);
+			return;
+		}
+		log.info("- writing to "+outputPath);
+
+		target.getParentFile().mkdirs();
+		FileWriter writer = new FileWriter(target, false);
+
+		try {
+			final char[] buffer = new char[BUFFER_SIZE];
+
+			// concatenate children
+			for (String child : manager.getDependencies(path)) {
+				// insert child files into outputFile
+				log.info("- adding "+child);
+				File source = manager.getTargetFile(child);
+				FileReader reader = new FileReader(source);
+				try {
+					int count;
+					while ((count = reader.read(buffer)) > 0) {
+						writer.write(buffer, 0, count);
+					}
+				} finally {
+					reader.close();
+				}
+			}
+
+		} finally {
+			writer.flush();
+			writer.close();
+		}
+	}
+
+	private void buildDebugPlaceholders(BuildManager manager, String path)
+		throws FileNotFoundException, IOException {
+		
+		String hashPath = manager.getProcessedPath(path);
+
+		List<String> children = manager.getDependencies(path);
+
+		if (children.size() == 1) {
+			// if only one child then use source file as the debugPath
+			manager.setProcessedPath(hashPath, children.get(0));
+			return;
+		}
+
+		// splice in the debug directory
+		int slash = hashPath.lastIndexOf('/');
+		String debugPath = hashPath.substring(0, slash)+"/debug"+hashPath.substring(slash);
+		manager.setProcessedPath(hashPath, debugPath);
+
+		File target = manager.getTargetFile(hashPath);
+		if (target.exists()) {
+			return;
+		}
+
+		String targetExt = BuildManager.getExtension(hashPath);
+		PlaceholderGenerator generator = this.placeholders.get(targetExt);
+		if (generator == null) {
+			log.warn("No debug placeholder generator found for "+targetExt);
+			return;
+		}
+
+		generator.build(target, children);
+	}
+}

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

 import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
-import java.util.Map;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 
 /**
  * Very basic "compactor" which simply copies the bits from source to target
 	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;
+	public String getTargetExtension(BuildManager manager, String path) {
+		return BuildManager.getExtension(path);
 	}
 
 	@Override
-	public void compact(Map<String, String> fileHashes, File source, File target, String path) throws IOException {
+	public void calcHash(BuildManager manager, MessageDigest hash, String path, File source)
+			throws IOException, NoSuchAlgorithmException {
+
+		FileInputStream stream = new FileInputStream(source);
+		try {
+			final byte[] buffer = new byte[BUFFER_SIZE];
+
+			int count;
+			while ((count = stream.read(buffer)) > 0) {
+				hash.update(buffer, 0, count);
+			}
+
+		} finally {
+			stream.close();
+		}
+	}
+
+	@Override
+	public void compact(BuildManager manager, String path, File source, File target)
+			throws IOException {
+
+		// ensure parent path exists
 		target.getParentFile().mkdirs();
 
 		FileInputStream inStream = new FileInputStream(source);

merge-builder/src/main/java/org/duelengine/merge/Settings.java

+package org.duelengine.merge;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+
+public class Settings {
+
+	private static final String[] EMPTY = new String[0];
+	private static final String DEFAULT_CDN_ROOT = "/cdn/";
+	private static final String DEFAULT_MAP_FILE = "cdn.properties";
+
+	private File cdnMapFile;
+	private String cdnRoot = DEFAULT_CDN_ROOT;
+	private File targetDir;
+	private File sourceDir;
+	private String[] extensions;
+
+	public File getCDNDir() {
+		return new File(getTargetDir(), this.cdnRoot);
+	}
+
+	public File getCDNMapFile() {
+		if (this.cdnMapFile == null) {
+			return new File(this.getTargetDir(), DEFAULT_MAP_FILE);
+		}
+
+		return this.cdnMapFile;
+	}
+
+	public void setCDNMapFile(String value) {
+		if (value == null || value.isEmpty()) {
+			this.cdnMapFile = null;
+			return;
+		}
+
+		value = value.replace('\\', '/');
+		if (!value.startsWith("/")) {
+			value = '/'+value;
+		}
+		this.cdnMapFile = new File(value);
+	}
+
+	public String getCDNRoot() {
+		return this.cdnRoot;
+	}
+
+	public void setCDNRoot(String value) {
+		if (value == null || value.isEmpty()) {
+			this.cdnRoot = DEFAULT_CDN_ROOT;
+			return;
+		}
+
+		value = value.replace('\\', '/');
+		if (!value.startsWith("/")) {
+			value = '/'+value;
+		}
+		if (!value.endsWith("/")) {
+			value += '/';
+		}
+
+		this.cdnRoot = value;
+	}
+
+	public String[] getExtensions() {
+		return this.extensions;
+	}
+
+	public void setExtensions(String... value) {
+		if (value == null) {
+			this.extensions = EMPTY;
+			return;
+		}
+
+		this.extensions = value;
+	}
+
+	public void setExtensionList(String value) {
+		if (value == null || value.isEmpty()) {
+			this.setExtensions(EMPTY);
+			return;
+		}
+
+		this.setExtensions(value.split("[|,\\s]+"));
+	}
+	
+	public File getSourceDir() {
+		return this.sourceDir;
+	}
+
+	public void setSourceDir(String value) {
+		if (value == null || value.isEmpty()) {
+			this.sourceDir = null;
+			return;
+		}
+
+		this.sourceDir = new File(value.replace('\\', '/'));
+	}
+
+	public File getTargetDir() {
+		if (this.targetDir == null) {
+			return this.getSourceDir();
+		}
+
+		return this.targetDir;
+	}
+
+	public void setTargetDir(String value) {
+		if (value == null || value.isEmpty()) {
+			this.targetDir = null;
+			return;
+		}
+
+		this.targetDir = new File(value.replace('\\', '/'));
+	}
+
+	//----------------
+
+	File getTargetFile(String targetPath) {
+		return new File(getTargetDir(), targetPath);
+	}
+
+	File findSourceFile(String path) {
+		File source = new File(getTargetDir()+path);
+		if (source.exists()) {
+			return source;
+		}
+
+		source = new File(getSourceDir()+path);
+		if (source.exists()) {
+			return source;
+		}
+
+		return null;
+	}
+
+	Map<File, String> findFiles(Set<String> extensions)
+		throws IOException {
+
+		Map<File, String> files = new LinkedHashMap<File, String>();
+
+		findFiles(files, extensions, getSourceDir());
+		findFiles(files, extensions, getTargetDir());
+
+		return files;
+	}
+
+	private void findFiles(Map<File, String> files, Set<String> extensions, File searchDir)
+			throws IOException {
+
+		final String cdnPath = getCDNDir().getCanonicalPath();
+		final int rootPrefix = searchDir.getCanonicalPath().length();
+		Queue<File> folders = new LinkedList<File>();
+
+		folders.add(searchDir);
+		while (!folders.isEmpty()) {
+			File file = folders.remove();
+
+			if (file.getCanonicalPath().startsWith(cdnPath)) {
+				// filter CDN output files
+				continue;
+			}
+
+			if (file.isDirectory()) {
+				folders.addAll(Arrays.asList(file.listFiles()));
+				continue;
+			}
+
+			String ext = BuildManager.getExtension(file.getCanonicalPath());
+			if (extensions.contains(ext)) {
+				files.put(file, file.getCanonicalPath().substring(rootPrefix));
+			}
+		}
+	}
+}

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

 import org.apache.maven.plugin.AbstractMojo;
 import org.apache.maven.plugin.MojoExecutionException;
 import org.apache.maven.plugin.logging.Log;
-import org.duelengine.merge.MergeBuilder;
+import org.duelengine.merge.Settings;
+import org.duelengine.merge.BuildManager;
 
 /**
  * Generates client-side and server-side sources
 
 		MavenLoggerAdapterFactory.setMavenLogger(log);
 	};
-	
+
 	public void execute()
 		throws MojoExecutionException {
-		
+
+		Settings settings = new Settings();
+		settings.setSourceDir(this.webappDir);
+		settings.setTargetDir(this.outputDir);
+		settings.setCDNMapFile(this.resourcesDir+this.cdnMapFile);
+		settings.setCDNRoot(this.cdnRoot);
+		settings.setExtensionList(this.cdnFiles);
+
 		Log log = this.getLog();
 
-		log.info("\twebappDir="+this.webappDir);
-		log.info("\toutputDir="+this.outputDir);
-
-		if (this.cdnMapFile == null || this.cdnMapFile.isEmpty()) {
-			this.cdnMapFile = "/cdn.properties";
-		} else if (!this.cdnMapFile.startsWith("/")) {
-			this.cdnMapFile = '/'+this.cdnMapFile;
-		}
-		log.info("\tcdnMapFile="+this.resourcesDir+this.cdnMapFile);
-		log.info("\tcdnRoot="+this.cdnRoot);
-
-		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);
-		merger.setOutputDir(this.outputDir);
-
-		if (this.cdnRoot != null && !this.cdnRoot.isEmpty()) {
-			merger.setCDNRoot(this.cdnRoot);
-		}
-
-		merger.setCDNMapFile(this.resourcesDir+this.cdnMapFile);
+		log.info("\tsourceDir="+settings.getSourceDir());
+		log.info("\ttargetDir="+settings.getTargetDir());
+		log.info("\tcdnMapFile="+settings.getCDNMapFile());
+		log.info("\tcdnRoot="+settings.getCDNRoot());
+		log.info("\tcdnFiles="+Arrays.toString(settings.getExtensions()));
 
 		try {
-			merger.execute();
+			new BuildManager(settings).execute();
 
-		} catch (Exception e) {
-			log.error(e);
+		} catch (Exception ex) {
+			log.error(ex);
 		}
 	}
 }