Aaron Greenlee.com | A Personal Journey.
You should follow me here.
This is the RSS subscription you have been looking for.
 
Wrecking Ball Media
A great team that is clearing the way for digital marketing.

Have you thought about joining the Central Florida Web Developers User Group?

Google Closure, ColdBox and ANT

POSTED Tuesday, January 4, 2011  |   | DOWNLOAD CODE
Keywords: Google Closure, ANT, ColdBox, ColdFusion, Interceptor

I find myself shifting more and more of my focus away from the server and onto the client. When building Web clients powered by JavaScript proper management of your assets (you know, the JavaScript and CSS files) becomes increasingly important. I wanted to take a moment and share my current solution for managing assets which allows me to reduce HTTP calls in production, make my source transparent in development and allow JavaScript to be tested directly in Eclipse thanks to JSTestDriver. This is all made possible by combining ColdFusion Builder/CFEclipse via Eclipse, ANT, Google Closure and Google's JSTestDriver (which can use QUnit, YUI Test or any other test framework.)

Project Goals for Deployment to Production

  1. I wanted to easily deploy via a CDN or my Web server.
  2. I wanted to combine my CSS files to reduce HTTP calls.
  3. I wanted to compile my JavaScript using Google Closure and reduce HTTP calls.

Project Goals for Development

  1. I wanted full transparency of the source files during development.
  2. I wanted to keep my source files separated to their areas of concern to encourage testing via JSTestDriver.

Getting ready for Production

I won't spend much time outlining the details of the following steps since the community has already invested many hours into documenting the minutia.

You need ANT. The easiest way to get ANT is through your IDE such as CFBuilder or CFEclipse. I use CFBuilder but the instructions should be similar for any Eclipse plugin since ANT is provided by Eclipse and not CFBuilder or CFEclipse.

Download Google Closure and place the jar in a directory called 'closure' that is a sibling to your project's directory. You can put it anywhere you want, but, you'll need to update the build.xml file if you do not follow my advice.

Customize the asset groups in build.xml (my example build.xml is later in this post) so they are appropriate for your project. Only you can do this, but, I have provided an example in the build.xml file provided. I have included comments in the build file as you will need to update quite a few things since this is project specific.

Save the build.xml file in the root of your project and associate the file to your project by opening CFBuilder and following these instructions:

  1. Right-click your project
  2. Click 'Builders'
  3. Click 'New', then 'Ant Builder', then on the main tab click 'Browser Workspace' and select the build.xml file.
  4. Set the 'Base Directory' to your workspace root.
  5. Click the 'Targets' tab and manually select all the targets for as many build groups as you may use. I only use 'manual'.

Compiling for Production

Once setup, you can compile by pressing CRTL+B in Windows or clicking the Project Menu and clicking Build Project. The console should appear and output the status of the build. In my project settings, I also have ANT append the results to a log file.

That's it! You've minified your JavaScript, minified your CSS and combined them into relevant groups!

Making the most with a ColdFusion ColdBox Application

So far, everything I have discussed is well adopted by all sorts of developers. But, I had some specific goals for keeping things separate in development. To accomplish my goal, I wrote a ColdBox interceptor that is only enabled on a workstation that parses the ANT build.xml file and includes the compiled file in production, stage and development while providing the individual source files while on a developer's workstation.

Example Event Handler
component
name="Example Event Handler for a ColdFusion ColdBox Application"
{
	function preHandler(event,action)
	{
		var rc = event.getCollection();

		// Tell the Event object what assets we want for this page.
		// The RequestContextDecorator will include the right
		// assets for your environment. You simply need to manage the
		// groups you need.

		// Include a single asset group. Order is important.
		// This will be included for all actions.
		Event.addAssetGroup('css/episode_manager');
		
		return;
	}

	function index(event)
	{
		var rc = event.getCollection();
		rc.secretMessage = 'Pigs fly';
		
		// Or, include multiple groups. Order is important.
		// These will only be included for the index action.
		Event.addAssetGroups('
			 css/episode_manager
			,js/common-lib
			,js/episode_manager'
		);
		
		return;
	}
}

In the above example, I have outlined how you can interface with the RequestContextDecorator for our ColdBox application to include the asset groups you need. You can include a single asset, or, a list of assets

The Files You Need

Snippet from ColdBox.cfc Config File
// Part of a ColdBox.cfc config file for ColdBox 3.0+ 

// IMPORTANT
// You will need to add the setting 'cdnpath' and ensure it contains
// the root path you will be deploying your production assets to.
// It can a directory on your web server (/cdn) or it can be another
// URL like http://xxxxxxxx.cdn.cloudfiles.rackspacecloud.com/

/** Workstation setup. Executed by ColdBox once the framework
	senses the workstation environment. */
public void function workstation() {
	// Behave like 'dev' enviroment.
	dev();

	// Include the client source files in this environment
	includeAssetSRC();
	
	// Anything else you desire for your workstation environment
	coldbox.handlerCaching 	= false;
	
	return;
}

/** Executed in all non-production environments **/
private function includeAssetSRC()
{
	//
	// Parse Build.XML
	//
	local.devInterceptor =
	{
		 class = 'interceptors.DevelopmentCDN'
		// Tell our interceptor where the ANT script is.
		,properties=
		{
			 buildFile=expandPath('build.xml')
			,xPathProperties = '/project/property'
			,xPathTargets = '/project/target'
			,alwaysReload = true
		}
	};
	
	// Change the setting of our CDN to include our source files.
	settings.cdnpath = '/src/';
	
	arrayPrepend( interceptors, local.devInterceptor);

	return;
}

The above snippit can be included in your /config/ColdBox.cfc for all ColdBox 3+ applications. Change the buildFile path to reflect the actual path to your build file if you remove it from your application root. Also, you may need to change the class path for the DevelopmentCDN interceptor if you do not place it in the conventional location.

CDN Development Interceptor
/**
 * Copyright Aaron Greenlee
 *
 * <h4>Description</h4>
 * Interceptor to include source assets on workstations.
 *
 * Created
 * 1/3/2011 11:00:03 AM
 *
 * @author Aaron Greenlee (http://aarongreenlee.com)
 * @version 1
 * @see N/A
 **/
component
	name='DevelopmentCDN'
	hint='A ColdBox 3 Interceptor to acceperate the development of applications
	where CSS and JS files are merged with an ANT Build Script and deployed to
	a CDN. For example, the author--Aaron Greenlee--uses Google Closure to
	compile JavaScript prior to publishing to a CDN. This Interceptor allows the
	original source files to be included while developing on a Workstation while
	eaisly allowing the Development, Staging and Production environments to load
	the compiled files.'
{
	void function configure()
	{
		// Param our property
		variables.alwaysReload = propertyExists('alwaysReload')
		? getProperty('alwaysReload')
		: true;

		// Validate
		local.requiredProperties =
		['buildFile','xPathProperties','xPathTargets'];

		local.requiredProperty = [];
		local.missing = [];
		for(local.requiredProperty in local.requiredProperties)
		{
			if (!propertyExists(local.requiredProperty))
			{
				arrayAppend(local.missing,local.requiredProperty);
			} else {
				variables[requiredProperty] =
				getProperty(local.requiredProperty);
			}
		}

		if (!arrayIsEmpty(local.missing))
		{
			throw(
				message='Missing Interceptor Configuration Settings'
				detail='The following setting(s) were not provided when
				configuring the DevelopmentCDN Interceptor:
				"#arrayToList(local.missing, "; ")#.'
				type='interceptors.Development'
			);
		}

		variables.locationOfBuildScript = variables.buildFile;

		// Validate the file exists
		if (!fileExists(variables.locationOfBuildScript))
		{
			throw(
				message='Build File Not Found!'
				detail='The ANT Build File "#variables.locationOfBuildScript#"
				could not be located. Please check the path provided within
				the ColdBox Configuration File.'
				type='interceptors.Development'
			);
		}

		return;
	}

	void function preProcess(Event,struct interceptData)
	{
		var rc = event.getCollection();
		var prc = event.getCollection(private=true);

		// Read out ANT Build Script and individually load the
		// CSS and JS that will be merged in production.
		prc.devAssets = parseBuildScript(Event);
		return;
	}

	// -------------------------------------------------------------------------
	// Private
	// -------------------------------------------------------------------------
	/**
	* 	Parse our ANT build script for CSS and JS that will be merged when
	*	built for production. When ran, the timestamp is saved and compared
	*	in future iterations. If the timestamp of the build file has not
	*	changed, no processing will occur.
	*/
	private struct function parseBuildScript(required Event)
	{
		//
		// Process
		//
		local.contents = fileRead(variables.locationOfBuildScript);
		local.BuildXML = xmlParse(local.contents,false);

		local.Properties = xmlSearch(local.BuildXML,variables.xPathProperties);
		local.Targets = xmlSearch(local.BuildXML,variables.xPathTargets);

		// Read the variables (properties) from the build script
		local.propertyValues = structNew();
		for(local.path in local.Properties)
		{
			// "${src.css.dir}" is an example of how this may list.
			local.propertyValues['${' & local.path.xmlAttributes.name & '}'] =
			local.path.xmlAttributes.location;
		}

		local.targetGroups = {};
		for (local.target in local.targets)
		{
			if (structKeyExists(local.target.xmlAttributes,'name'))
			{
				local.targetName = local.target.xmlAttributes.name;

				local.type = left(local.targetName, 2);

				if (local.type == 'cs' || local.type == 'js')
				{
					// What XPaths do we need?
					local.XPaths = (local.type == 'cs')
					? {	 group = "/project/target[@name='#local.targetName#']/concat/filelist/"
					 	,files = "/project/target[@name='#local.targetName#']/concat/filelist/file"}
					: {	 group = "/project/target[@name='#local.targetName#']/jscomp/sources/"
					 	,files = "/project/target[@name='#local.targetName#']/jscomp/sources/file"};
					// Parse our files into the group.
					local.targetGroups[local.targetName] = parseFiles(
						 local.target
						,local.propertyValues
						,local.XPaths
						,local.targetName
					);
				}
			}
		}
		variables.parsedAssets = local.targetGroups;
		return variables.parsedAssets;
	}

	/** Parse CSS from build file and return the results as an array.**/
	private array function parseFiles (
		 required xml xmlNode
		,required struct propertyValues
		,required struct XPaths
		,required string TargetName)
	{
		// Get Directory
		local.groups = xmlSearch(xmlNode, arguments.XPaths.Group);

		if (arrayIsEmpty(local.groups))
		{
			return [];
		}

		// Result
		local.r = [];

		for (local.group in local.groups)
		{
			// Don't confirm a directory as its for dev only
			local.dir = local.group.XmlAttributes.dir & '/';

			// Replace the directory variable with the value (if it is a variable)
			if (findNoCase('${',local.dir) > 0)
			{
				for (local.k in arguments.propertyValues)
				{
					local.dir = replaceNoCase(
						 local.dir
						,local.k
						,arguments.propertyValues[local.k]
						,'all'
					);
				}
			}

			if (structKeyExists(local.group, 'XMLChildren'))
			{
				// Get Files
				local.files = local.group.XMLChildren;

				for(local.file in local.files)
				{
					// Concatinate and ensure no double slashes
					local.filePath = rereplace(
						 local.dir & local.file.XmlAttributes.name
						,'/////|///|//'
						,'/'
						,'all'
					);
					arrayAppend(local.r, local.filePath);
				}
			}
		}
		return local.r;
	}
}

No changes should be required within the interceptor.

Request Context Decorator
/** Decorate the Request Context */
component extends="coldbox.system.web.context.RequestContextDecorator" {
	// -------------------------------------------------------------------------
	// Configure
	// -------------------------------------------------------------------------
	public void function configure () {
		var rc = getRequestContext().getCollection();
		var prc = getRequestContext().getCollection(private = true);

		// Prepare asset structures
		clearAssetsGroup();

		return;
	}
	// -------------------------------------------------------------------------
	// Public 
	// -------------------------------------------------------------------------
	/** Allow assets to be loaded into the queue */
	public void function addAssetGroup(required string group)
	{
		var prc = getRequestContext().getCollection(private = true);

		if (!structKeyExists(prc, 'assets_group'))
		{
			clearAssetsGroup();
		}

		local.type = trim(listfirst(arguments.group, '/'));

		if (!arrayContains(prc.assets_group[local.type], arguments.group))
		{
			arrayAppend(prc.assets_group[local.type], trim(arguments.group));
		}

		return;
	}

	/** Allow multiple assets to be loaded into the queue */
	public void function addAssetGroups(required string groups) {
		var prc = getRequestContext().getCollection(private = true);
		local.list = listToArray(arguments.groups, ',');

		// Add each path individually
		var n = arraylen(local.list);
		var i = 0;
		for (i=1; i <= n; i++)
		{
			addAssetGroup(group=local.list[i]);
		}
		return;
	}

	/** Return the assets as HTML links. The output is appropriate for the
		environment. */
	public string function getAssetsGroup(required string type) {
		var prc = getRequestContext().getCollection(private = true);

		if (!structKeyExists(prc, 'assets_group'))
		{
			clearAssetsGroup();
		}

		local.r = [];

		// On a workstation, include the source files. This is not controlled
		// via a Config Setting by design to help expose problems in the DEV
		// environment
		local.includeSourceFiles = getSetting() == 'workstation';

		for (local.group in prc.assets_group[arguments.type])
		{
			if(local.includeSourceFiles)
			{
				for (local.file in prc.devAssets[local.group])
				{
					local.html = (arguments.type == 'js')
					? '<script src="/'& local.file &'"></script>'
					: '<link rel="stylesheet" href="/'& local.file &'">';
					arrayAppend(local.r, local.html);
				}
			} else {
				local.html = (arguments.type == 'js')
				? '<script src="'& getSetting('cdnpath') & '/' & local.group &'.js"></script>'
				: '<link rel="stylesheet" href="'& getSetting('cdnpath') & '/' &  local.group &'.css">';
				arrayAppend(local.r, local.html);
			}
		}
		return arrayToList(local.r,'');
	}

	public void function clearAssetsGroup()
	{
		var prc = getRequestContext().getCollection(private = true);
		prc.assets_group = {js=[],css=[]};
		return;
	}
}

If you change the name of the environments that should include source code, you will need to update the RequestContextDecorator to change 'workstation' to the environment(s) you desire.

Build.xml
<project name="Asset ANT Build" default="clean" basedir=".">
	<description>
		Build the application.
	</description>

	<!-- global properties -->
	<property name="building.dir" location="_building"/>
	<property name="cdn.dir" location="cdn/"/>
	<property name="src.dir" location="src/"/>
	<property name="src.css.dir" location="src/css/"/>
	<property name="src.js.dir" location="src/js/"/>

	<taskdef name="jscomp"
		classname="com.google.javascript.jscomp.ant.CompileTask"
		classpath="../closure/compiler.jar"/>
	
	<target name="init">
		<!-- Create the time stamp -->
		<tstamp/>
		<!-- Create Temp Build Dir -->
		<mkdir dir="${building.dir}" />
	</target>

	<target name="css/episode_manager" depends="init">
		<concat
			destfile="${cdn.dir}/css/episode_manager.css"
			fixlastline="yes">
			<filelist
				id="CSS"
				dir="${src.css.dir}/">
				<!-- Lib -->
				<file name="lib/jquery-ui-1.7.2.custom.css"/>
				<file name="lib/ui.datepicker.2.css"/>
				<file name="lib/humanmsg.css"/>
				<!-- Application -->
				<file name="global.css"/>
				<file name="layout.css"/>
				<!-- Episode Manager -->
				<file name="EpisodeManager/SelectionScreen.css"/>
			</filelist>
		</concat>	
	</target>
	
	<!--
	Google Closure
	warning: quiet, verbose and default
	-->		
	<target
		name="js/common-lib"
		description="Google Closure">
		<jscomp
			compilationLevel="whitespace"
			warning="quiet" 
	        debug="false"
			output="${cdn.dir}/js/common-lib.js">
			<sources dir="${src.js.dir}/lib">
				<file name="jquery-1.4.4.min.js"/>
				<file name="jquery.tmpl.min.js"/>
				<file name="underscore-1.1.3.min.js"/>
				<file name="backbone-0.3.3.min.js"/>
			</sources>
		</jscomp>
	</target>
	
	<target
		name="js/episode_manager"
		description="Google Closure">
		<jscomp
			compilationLevel="simple"
			warning="default" 
	        debug="false"
			output="${cdn.dir}/js/episode_manager.js">
			
			<sources dir="${src.js.dir}/model">
				<file name="Show.js"/>
			</sources>
			<sources dir="${src.js.dir}/collection">
				<file name="Shows.js"/>
			</sources>
		</jscomp>
	</target>
	
	<target name="clean"
			description="Clean up">
		<delete dir="${building.dir}" />
	</target>
</project>

Finally, the example ANT build script.

blog comments powered by Disqus
You can send me email or work with
   me for digital marketing, web design
      and application development.

         I own proprietary web application development
         company and work with a leading digital marketing firm.
© 2009 - 2013 Aaron Greenlee. Powered by my own code on the ColdBox Framework.

This site is best viewed on Chrome, FireFox and Safari. Subscribe to my RSS feed.