Skip to content

Instantly share code, notes, and snippets.

@benatwork99
Created October 21, 2011 05:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save benatwork99/1303164 to your computer and use it in GitHub Desktop.
Save benatwork99/1303164 to your computer and use it in GitHub Desktop.
webminifier-maven-plugin
Minification of web resources
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Insert title here</title>
<script type="text/javascript" src="a.js"></script>
<script type="text/javascript" src="b.js"></script>
<script type="text/javascript" src="c.js"></script>
<script type="text/javascript"></script>
</head>
<body>
</body>
</html>
package org.codehaus.mojo.webminifier;
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.cyberneko.html.parsers.DOMParser;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/**
* Responsible identifying resource statements in a document and providing the means to replace them.
*/
public class DocumentResourceReplacer
{
private final Document document;
private final File documentParentFile;
private final DOMParser parser;
/**
* @param htmlFile the html document.
* @throws IOException if something goes wrong.
* @throws SAXException if something goes wrong.
*/
public DocumentResourceReplacer( File htmlFile )
throws SAXException, IOException
{
parser = new DOMParser();
parser.parse( htmlFile.toString() );
documentParentFile = htmlFile.getParentFile();
document = parser.getDocument();
}
/**
* @return a list of JS script declarations returned as files.
*/
public List<File> findJSResources()
{
List<File> jsResources = new ArrayList<File>();
// Get all <script> tags from the document
NodeList scriptNodes = document.getElementsByTagName( "script" );
for ( int i = 0; i < scriptNodes.getLength(); i++ )
{
Node scriptNode = scriptNodes.item( i );
NamedNodeMap scriptAttrNodes = scriptNode.getAttributes();
if ( scriptAttrNodes != null )
{
Attr srcAttrNode = (Attr) scriptAttrNodes.getNamedItem( "src" );
if ( srcAttrNode != null )
{
String jsSrc = srcAttrNode.getValue();
// If it has a SRC which can be resolved
File scriptFile = new File( documentParentFile, jsSrc );
if ( scriptFile.isFile() )
{
jsResources.add( scriptFile );
}
}
}
}
return jsResources;
}
/**
* @return the html source as a string.
* @throws TransformerException if something does wrong.
* <p>
* FIXME: Is there a better way to do this i.e. can we obtain an input stream from the document. May be
* better from a memory perspective?
*/
private String getHTMLSource()
throws TransformerException
{
// Use a Transformer for output
TransformerFactory transformerFactory = TransformerFactory.newInstance();
Transformer transformer = transformerFactory.newTransformer();
DOMSource source = new DOMSource( document );
StringWriter writer = new StringWriter();
StreamResult result = new StreamResult( writer );
transformer.transform( source, result );
return writer.toString();
}
/**
* Replace the script statements that exist in the document with the new set.
*
* @param documentDir the folder that represents the root.
* @param jsResources the new set.
*/
public void replaceJSResources( File documentDir, Set<File> jsResources )
{
// Get and remove all SCRIPT elements
NodeList scriptNodes = document.getElementsByTagName( "script" );
while ( scriptNodes.getLength() > 0 )
{
// Remove existing script nodes
Node scriptNode = scriptNodes.item( 0 );
scriptNode.getParentNode().removeChild( scriptNode );
}
// Note the head node to add to.
NodeList headElements = document.getElementsByTagName( "head" );
if ( headElements.getLength() == 1 )
{
Node headElement = headElements.item( 0 );
// Insert new SCRIPT elements for all replaced resources
String documentUri = documentDir.getParentFile().toURI().toString();
for ( File jsResource : jsResources )
{
String jsResourceRelUri = jsResource.toURI().toString();
jsResourceRelUri = jsResourceRelUri.substring( documentUri.length() );
Element jsElement = document.createElement( "script" );
jsElement.setAttribute( "type", "text/javascript" );
jsElement.setAttribute( "src", jsResourceRelUri );
headElement.appendChild( jsElement );
}
}
}
/**
* Write out the html source for the current document.
*
* @param htmlFile the file to write.
* @param encoding the encoding to use.
* @throws TransformerException if something goes wrong.
* @throws IOException there is a problem writing the file.
*/
public void writeHTML( File htmlFile, String encoding )
throws TransformerException, IOException
{
OutputStream fos = new FileOutputStream( htmlFile );
try
{
OutputStreamWriter updatedHTMLWriter = new OutputStreamWriter( new BufferedOutputStream( fos ), encoding );
try
{
updatedHTMLWriter.write( getHTMLSource() );
}
finally
{
updatedHTMLWriter.close();
}
}
finally
{
fos.close();
}
}
}
package org.codehaus.mojo.webminifier;
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import static org.junit.Assert.assertEquals;
import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.xml.transform.TransformerException;
import org.junit.Before;
import org.junit.Test;
import org.xml.sax.SAXException;
/**
* Test the document replacer.
*
* @author huntc
*/
public class DocumentResourceReplacerTest
{
private DocumentResourceReplacer replacer;
private File html;
/**
* Setup.
*
* @throws IOException if something goes wrong.
* @throws SAXException if something goes wrong.
* @throws URISyntaxException if something goes wrong.
*/
@Before
public void setUp()
throws SAXException, IOException, URISyntaxException
{
URL url = DocumentResourceReplacer.class.getResource( "a.html" );
html = new File( url.toURI() );
replacer = new DocumentResourceReplacer( html );
}
/**
* Test that JS files can be extracted from the parsed document.
*/
@Test
public void testFindJSResources()
{
List<File> jsFiles = replacer.findJSResources();
assertEquals( 3, jsFiles.size() );
assertEquals( "a.js", jsFiles.get( 0 ).getName() );
assertEquals( "b.js", jsFiles.get( 1 ).getName() );
assertEquals( "c.js", jsFiles.get( 2 ).getName() );
}
/**
* Test that we can successfully replace what we have as script elements with a new one.
*
* @throws URISyntaxException if something goes wrong.
*/
@Test
public void testReplaceJSResources()
throws URISyntaxException
{
Set<File> jsResources = new HashSet<File>( 1 );
URL url = DocumentResourceReplacer.class.getResource( "d.js" );
File js = new File( url.toURI() );
jsResources.add( js );
replacer.replaceJSResources( html, jsResources );
List<File> jsFiles = replacer.findJSResources();
assertEquals( 1, jsFiles.size() );
assertEquals( "d.js", jsFiles.get( 0 ).getName() );
}
/**
* Test that we can successfully write out the html document.
*
* @throws IOException if something goes wrong.
* @throws TransformerException if something goes wrong.
*/
@Test
public void testWriteHTML()
throws IOException, TransformerException
{
File htmlFile = File.createTempFile( "tempHtml", ".html" );
replacer.writeHTML( htmlFile, "UTF-8" );
final long expectedLength = 417L;
assertEquals( expectedLength, htmlFile.length() );
htmlFile.delete();
}
}
/*global $, _, Mustache, window */
/**
* @class Generic search dialog
*
* @import com.jqueryui:jqueryui
*/
function FilterDialog(options) {
$.extend(this,{
heigth : 90,
width : 200,
title : "Filter"
});
$.extend(this, options);
}
FilterDialog.prototype.search = function(template,notify,element,data,callBack) {
var self = this, renderedHtml, x, y, grid,cell;
renderedHtml = $(Mustache.to_html(template, data));
$("#dialog-search").empty().append(renderedHtml);
grid = $(element).closest(".gridContainer");
cell = $(element).parent().parent();
x = $(cell).offset().left + $(cell).outerWidth();
y = $(cell).offset().top - $(document).scrollTop();
$("#dialog-search").dialog({
resizable: false,
height:self.heigth,
width: self.width,
modal: true,
title: self.title,
position: [x,y],
close: function(event, ui){
callBack(event,ui);
}
/* buttons: {
"Ok" : function() {
$(this).dialog("close");
success();
},
Cancel: function() {
$(this).dialog("close");
}
}*/
});
$("#dialog-search input").keyup(function(e) {
var searchString;
if (e.which === 27){
this.value = "";
}
notify(this.name,this.value);
});
//Listen for checkbox clicks
$("#dialog-search input:checkbox").change(function(e)
{
notify(this.name,this.checked);
});
};
h1. Web Minifier Maven Plugin
h2. Overview
This plugin provides JavaScript and CSS minification for Maven projects. It produces a minified version of your JavaScript and CSS resources which can be used to construct a minified final artefact. It is designed to be flexible in the way it operates to allow for easy minified resource re-use across your project.
Under the hood, it currently uses the [YUI Compressor|http://developer.yahoo.com/yui/compressor/] but has a layer of abstraction around the minification tool which allows for other minification tools to potentially be used.
h2. Default Behaviour
The default behaviour of the plugin is like so:
- Copy target/classes to /target/min/classes
- For each HTML file in /target/min/classes
-- Find all JavaScript script references
-- Join all the referenced JavaScript files together into a new JavaScript file in the same order as the original references
-- Minify the merged JavaScript file
-- Replace the script references in the HTML file with a reference to the merged, minified script
-- Find all CSS file references
-- Join all the references CSS files together into a new CSS file in the same order as the original references
-- Minify the merged CSS file
-- Replace the CSS references in the HTML file with a reference to the merged, minified script
h2. Minified Resource Grouping with Split Points
The plugin also supports more configurable behaviour; you can give it any number of JavaScript or CSS file names which are to be used as 'split points'; if a split point is reached when collating the list of resources within a HTML file then resources up to and including that point are merged into a single file, and a new merged file begins at the following resource, if it exists.
For example, if a HTML file contains the following script references:
- a.js
- b.js
- c.js
- d.js
- e.js
By default, these would all be merged into a new file 1.js and your HTML script references replaced by a single one, like:
- 1.js (containing a.js, b.js, c.js, d.js and e.js)
If you configured a JavaScript split point of 'c.js', the HTML file could then contain the following script references:
- 1.js (containing a.js, b.js and c.js)
- 2.js (containing d.js and e.js)
package org.codehaus.mojo.webminifier;
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import org.apache.maven.plugin.logging.Log;
import org.mozilla.javascript.ErrorReporter;
import org.mozilla.javascript.EvaluatorException;
/**
* A Rhino compatible error reporter.
*/
public class JavaScriptErrorReporter
implements ErrorReporter
{
private final Log logger;
/**
* @param logger the logger to use for reporting.
*/
public JavaScriptErrorReporter( Log logger )
{
this.logger = logger;
}
private String constructMessage( String type, String message, String sourceName, int line, String lineSource,
int lineOffset )
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append( type );
if ( message != null )
{
stringBuilder.append( ": " + message );
}
if ( sourceName != null && lineSource != null )
{
stringBuilder.append( " - " + sourceName );
stringBuilder.append( ":" + Integer.valueOf( line ) );
stringBuilder.append( ":" + Integer.valueOf( lineOffset ) );
stringBuilder.append( ":" + lineSource );
}
return stringBuilder.toString();
}
/**
* {@inheritDoc}
*/
public void error( String message, String sourceName, int line, String lineSource, int lineOffset )
{
logger.error( constructMessage( "Error", message, sourceName, line, lineSource, lineOffset ) );
}
/**
* {@inheritDoc}
*/
public EvaluatorException runtimeError( String message, String sourceName, int line, String lineSource,
int lineOffset )
{
logger.error( constructMessage( "Runtime error", message, sourceName, line, lineSource, lineOffset ) );
return new EvaluatorException( message, sourceName, line, lineSource, lineOffset );
}
/**
* {@inheritDoc}
*/
public void warning( String message, String sourceName, int line, String lineSource, int lineOffset )
{
logger.warn( constructMessage( "Warning", message, sourceName, line, lineSource, lineOffset ) );
}
}
package org.codehaus.mojo.webminifier;
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import org.apache.maven.plugin.logging.Log;
import org.junit.Before;
import org.junit.Test;
import org.mozilla.javascript.EvaluatorException;
/**
* Test error reporting.
*
* @author huntc
*/
public class JavaScriptErrorReporterTest
{
private JavaScriptErrorReporter reporter;
private Log logger;
private static final int TEST_LINENO = 100;
private static final int TEST_COLNO = 30;
/**
* Setup the tests.
*/
@Before
public void setUp()
{
logger = mock( Log.class );
reporter = new JavaScriptErrorReporter( logger );
}
/**
* Error format.
*/
@Test
public void testError()
{
reporter.error( "Whoops", "badsource.js", TEST_LINENO, "var a", TEST_COLNO );
verify( logger ).error( "Error: Whoops - badsource.js:100:30:var a" );
}
/**
* Runtime format.
*/
@Test
public void testRuntimeError()
{
EvaluatorException e = reporter.runtimeError( "Whoops", "badsource.js", TEST_LINENO, "var a", TEST_COLNO );
verify( logger ).error( "Runtime error: Whoops - badsource.js:100:30:var a" );
assertEquals( "Whoops (badsource.js#100)", e.getMessage() );
}
/**
* Warning.
*/
@Test
public void testWarning()
{
reporter.warning( null, null, TEST_LINENO, null, TEST_COLNO );
reporter.warning( null, "badsource.js", TEST_LINENO, null, TEST_COLNO );
verify( logger, times( 2 ) ).warn( "Warning" );
}
}
<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/maven-v4_0_0.xsd">
<parent>
<groupId>org.codehaus.mojo</groupId>
<artifactId>mojo-parent</artifactId>
<version>28</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>org.codehaus.mojo</groupId>
<artifactId>webminifier-maven-plugin</artifactId>
<packaging>maven-plugin</packaging>
<version>0.0.1-SNAPSHOT</version>
<name>Web Minifier Maven Plugin</name>
<description>Provides JS minification capabilities.</description>
<prerequisites>
<maven>3.0</maven>
</prerequisites>
<ciManagement>
<system>Codehaus Bamboo</system>
<url>http://bamboo.ci.codehaus.org/browse/MOJO-WEBMINI</url>
</ciManagement>
<inceptionYear>2011</inceptionYear>
<licenses>
<license>
<name>The Apache Software License, Version 2.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
<distribution>repo</distribution>
</license>
</licenses>
<developers>
<developer>
<id>benjones.selera</id>
<name>Ben Jones</name>
<roles>
<role>Developer</role>
</roles>
<timezone>10</timezone>
</developer>
<developer>
<name>Christopher Hunt</name>
<organization>Class Action PL</organization>
<organizationUrl>http://www.classactionpl.com/</organizationUrl>
<roles>
<role>Developer</role>
</roles>
<timezone>10</timezone>
</developer>
</developers>
<scm>
<connection>scm:svn:http://svn.codehaus.org/mojo/trunk/sandbox/webminifier-plugin</connection>
<developerConnection>scm:svn:https://svn.codehaus.org/mojo/trunk/sandbox/webminifier-plugin</developerConnection>
<url>http://svn.codehaus.org/mojo/trunk/sandbox/webminifier-plugin</url>
</scm>
<properties>
<mojo.java.target>1.5</mojo.java.target>
<!-- For Velocity filtering - can't use dot notations -->
<projectVersion>${project.version}</projectVersion>
</properties>
<dependencies>
<!-- Compile dependencies -->
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-plugin-api</artifactId>
</dependency>
<dependency>
<groupId>org.sonatype.plexus</groupId>
<artifactId>plexus-build-api</artifactId>
<version>0.0.4</version>
</dependency>
<dependency>
<groupId>org.codehaus.plexus</groupId>
<artifactId>plexus-utils</artifactId>
<version>1.5.15</version>
</dependency>
<dependency>
<groupId>net.sourceforge.nekohtml</groupId>
<artifactId>nekohtml</artifactId>
<version>1.9.15</version>
</dependency>
<dependency>
<groupId>com.yahoo.platform.yui</groupId>
<artifactId>yuicompressor</artifactId>
<version>2.4.6</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>1.4</version>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId>
<version>1.8.5</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-site-plugin</artifactId>
<version>3.0</version>
<dependencies>
<dependency>
<groupId>org.apache.maven.doxia</groupId>
<artifactId>doxia-module-confluence</artifactId>
<version>1.1.3</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<configuration>
<excludeModules>apt</excludeModules>
<reportPlugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>findbugs-maven-plugin</artifactId>
<version>2.3.1</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>cobertura-maven-plugin</artifactId>
<version>2.4</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>2.7</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-project-info-reports-plugin</artifactId>
<version>2.2</version>
<configuration>
<dependencyLocationsEnabled>false</dependencyLocationsEnabled>
</configuration>
</plugin>
</reportPlugins>
</configuration>
</plugin>
</plugins>
</build>
</project>
<?xml version="1.0" encoding="UTF-8"?>
<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.codehaus</groupId>
<artifactId>webminifier-testproject</artifactId>
<packaging>pom</packaging>
<version>1.0.0-SNAPSHOT</version>
<name>webminifier-testproject</name>
<properties>
<org.codehaus.jstestrunner.commandPattern>C:\development\phantomjs-1.2.0-win32-dynamic\phantomjs.exe '%1$s' %2$s</org.codehaus.jstestrunner.commandPattern>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Test -->
<dependency>
<groupId>com.jquery</groupId>
<artifactId>qunit</artifactId>
<version>25e4489</version>
<type>js</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.codehaus.jstestrunner</groupId>
<artifactId>jstestrunner-junit</artifactId>
<version>0.0.1-SNAPSHOT</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<testResources>
<testResource>
<directory>src/test/resources</directory>
</testResource>
<testResource>
<directory>src/test/js</directory>
<includes>
<include>**/*.js</include>
</includes>
<targetPath>js</targetPath>
</testResource>
</testResources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>2.5</version>
<executions>
<execution>
<goals>
<goal>resources</goal>
<goal>testResources</goal>
</goals>
</execution>
<execution>
<id>default-testResources</id>
<phase>process-test-resources</phase>
<goals>
<goal>testResources</goal>
</goals>
</execution>
</executions>
<configuration>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.3.2</version>
<configuration>
<source>1.5</source>
<target>1.5</target>
<encoding>ISO-8859-15</encoding>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.9</version>
<configuration>
<systemPropertyVariables>
<org.codehaus.jstestrunner.commandPattern>${org.codehaus.jstestrunner.commandPattern}</org.codehaus.jstestrunner.commandPattern>
</systemPropertyVariables>
<includes>
<include>**/*UT.java</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<goal>test</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>webminifier-maven-plugin</artifactId>
<version>0.0.1-SNAPSHOT</version>
<executions>
<execution>
<goals>
<goal>minify-js</goal>
</goals>
</execution>
</executions>
<configuration>
<jsSplitPoints>
<param>framework.js</param>
</jsSplitPoints>
</configuration>
</plugin>
</plugins>
</build>
</project>
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<project xmlns="http://maven.apache.org/DECORATION/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/DECORATION/1.0.0 http://maven.apache.org/xsd/decoration-1.0.0.xsd"
name="JS Minifier Maven plugin">
<body>
<menu name="Overview">
<item name="Introduction" href="index.html"/>
<item name="Usage" href="usage.html"/>
<item name="Goals" href="plugin-info.html"/>
</menu>
</body>
</project>
<html>
<head>
<script type="text/javascript" src="utils.js"></script>
<script type="text/javascript" src="framework.js"></script>
<script type="text/javascript" src="ui.js"></script>
<link rel="stylesheet" type="text/css" href="../css/qunit.css" />
</head>
<body>
</body>
</html>
/*global $, _, ISO8601Date, jGrowl, jQueryExtensions, jqueryValidateElement, Mustache, window */
/**
* @import com.jqueryui:jqueryui
* @import com.trentrichardson.timepicker:timepicker
*/
/**
* @class A class providing the ability to render a question matrix given an array of
* question groups.
*/
function QuestionsWidget(options) {
$.extend(this, options);
}
/**
* Our root div for referencing other elements from.
*/
QuestionsWidget.prototype.div = undefined;
/**
* Declares the location of category templates that are used to layout the questions.
*/
QuestionsWidget.prototype.categoryTemplateUrl = undefined;
/**
* Called when a response input has changed.
*/
QuestionsWidget.prototype.onResponseChanged = undefined;
/**
* Logic to filter question options by various criteria (e.g. crop, country, etc.)
*/
QuestionsWidget.prototype.optionFilter = undefined;
/**
* The URL used to post response attachments to, and fetch URI attachments from.
*/
QuestionsWidget.prototype._responseAttachmentUrl = undefined;
/**
* For one-off initialisation.
*/
QuestionsWidget.prototype._inited = false;
/**
* If locked then this widget will operate in 'locked' mode, where the form
* is still visible but cannot be interacted with.
*/
QuestionsWidget.prototype._locked = false;
/**
* The progress indicator used. Requires the div passed in to contain a child
* image element with a class of ".questionsProgress".
*/
QuestionsWidget.prototype._progressInd = undefined;
/**
* The questions form element. Requires the div passed in to contain a child
* form with an id of "#questionsForm".
*/
QuestionsWidget.prototype._questionsForm = undefined;
/**
* Set when the template is loaded. Used to coordinate responses for questions
* coming in prior to the template.
*/
QuestionsWidget.prototype._questionsTemplateLoaded = false;
/**
* If locked then this widget will operate in read-only mode, where no form
* elements are present, just plain text data.
*/
QuestionsWidget.prototype._readOnly = false;
/**
* The responses to render/have rendered. Responses are a bag keyed by
* questionId. Each question value is a bag of responses keyed by sequence.
*/
QuestionsWidget.prototype._responses = null;
/**
* A 2D array ([QUESTIONID][SEQUENCE]) of callback functions objects which are
* inspected when we receive a response to a saveResponses call. The
* callback is called with the response object as the first parameter.
*/
QuestionsWidget.prototype._responseCallbacks = [];
/**
* Question dependencies which we use to re-evaluate whether a question
* needs to be shown or not.
*/
QuestionsWidget.prototype._questionDependencies = null;
/**
* The element which is currently uploading a file. We use this variable to
* retain the element because we POST the upload to a hidden iframe and need
* to then link the completed upload to a question.
*/
QuestionsWidget.prototype._uploadingQuestion = null;
/**
* A constant for representing no selection.
*/
QuestionsWidget.prototype._SELECT_TEXT = _("Select...");
/**
* Initialiser. Can be called multiple times.
*
* Required parameters set to init():
* div
*
* @param locked
* True if the regulatory activity which these questions are for is locked
* @param readOnly
* True if we want to display only, for reporting (i.e., no input fields just text).
*/
QuestionsWidget.prototype.init = function(locked, readOnly) {
var self = this;
if (!self._inited) {
self._progressInd = $(".questionsProgress", self.div);
self._questionsForm = $("form.questionsForm", self.div);
self._inited = true;
}
// Clear the question dependencies
self._questionDependencies = null;
// We always start in read/write mode
self._locked = locked;
self._readOnly = readOnly;
};
/**
* Does the questions form pass validation?
*
* @return True if all fields validate, false otherwise.
*/
QuestionsWidget.prototype.isFormValid = function() {
var self = this, questionsValid, validationTopPosition;
questionsValid = true;
// Validate all questions.
// If validation fails, then error messages are shown to the user automatically.
$("[name^='Q']", self._questionsForm).each(
function() {
if ( !self.validateQuestionResponse(this)) {
questionsValid = false;
// Store the top of the message position
if (!validationTopPosition || validationTopPosition > $(this).next().offset().top) {
validationTopPosition = $(this).next().offset().top;
}
}
}
);
// If validation failed, scroll to top of first failed validation message
if (validationTopPosition) {
// Remove the height above the questions form and 10 pixels to give a little padding
validationTopPosition -= $(self._questionsForm).offset().top;
validationTopPosition -= 10;
// Only trigger if we're not at the right spot already
if ($(self._questionsForm).scrollTop() !== validationTopPosition) {
$(self._questionsForm).animate({scrollTop:validationTopPosition}, "slow");
}
}
return questionsValid;
};
/**
* Validate the changed response. If valid, continue processing and attempt to
* save. If invalid, show the user some feedback and halt processing of this
* response change event.
*
* @param event
* The trigger event object
*/
QuestionsWidget.prototype.validateResponseChanged = function(event) {
var self = this, scrollPosition;
// Attempt validation of the triggering element. If validation fails, then
// an error message is shown to the user automatically. If warning validation
// fails, validation message will be shown but processing will continue.
if (self.validateQuestionResponse(event.target, true)) {
// If validation succeeded then continue processing the event
self.onResponseChanged();
}
};
/**
* Validate the question response value.
* Show any validation messages to the user.
*
* @param element The element to validate.
* @param onChangeEvent True if this validation was triggered by a change event.
* @return True if validation succeeds.
*/
QuestionsWidget.prototype.validateQuestionResponse = function(element, onChangeEvent) {
var self = this, validationMessageObject, validity;
validity = true;
// Hide any validation messages
if (element.validationMessageObject) {
element.validationMessageObject.hide();
}
// Don't validate disabled dependency questions
if ($(element).prop('disabled') && !$(element).hasClass('fileDisplay') && $(element).closest('.dependency-container').length===1) {
return true;
}
// For a change event, allow empty values to pass validation. We do this so that a required
// question can be deleted - by changing it to empty. Required validation still takes effect,
// along with all other validation, when 'next task' is called.
// Checkboxes only have two states so always allow them here. Anything else, allow an empty
// value.
if (onChangeEvent
&& (($(element).prop("type") === "checkbox") || ($(element).val() === ""))) {
return true;
}
// Attempt validation of the passed element for errors.
validity = $(element).validateElement('validate');
// If we have any errors to show:
if ($(element).hasClass('validation-error') || $(element).hasClass('validation-warning')) {
// Construct the new validation message object
validationMessageObject = {
messageElement : $(element).next(),
hide : function() {
this.messageElement.stop(true)
.fadeOut();
},
show : function() {
// We do a dummy animation here - from 0.8 to 0.8 opacity -
// to allow us to use stop() to stop it. delay() won't let
// us break the effect - we could handle this with setTimeout
// / clearTimeout() but this is much cleaner except for the
// misuse of animate().
this.messageElement.stop(true)
.show()
.css('opacity',0.8)
.animate({'opacity': 0.8}, 5000)
.fadeOut();
}
};
// Associate the message with the question and show the message
element.validationMessageObject = validationMessageObject;
validationMessageObject.show();
}
return validity;
};
/**
* Show that we are waiting for questions to be retrieved.
*/
QuestionsWidget.prototype.showRequestInProgress = function() {
var self = this;
self._progressInd.fadeIn().show();
};
/**
* Show questions in the matrix.
*
* @param category
* an array of questions with question groups, label groups etc.
* @param afterShowCallback
* A callback to run after questions have been shown
*/
QuestionsWidget.prototype.showData = function(category, afterShowCallback) {
var self = this, categoryFilename, view;
// Build a view representation of our questions
view = self.buildViewFromQuestionGroups(category.questionGroups, category.optionTypeResponse);
// We now have a view representation of our data so get mustache to render it.
self._questionsTemplateLoaded = false;
// Give i18n a chance to translate the filename
categoryFilename = _(category.categoryId + ".mustache", "filename");
$.get(self.categoryTemplateUrl + "/" + categoryFilename, function(template) {
self.showQuestions(template, view, category);
}).complete( function() {
// After success or error, execute the callback if it was supplied
if (afterShowCallback) {
afterShowCallback();
}
});
// We're finished!
self._progressInd.fadeOut().hide();
};
/**
* Setup the HTML within our div to display the questions.
*
* @param template The Mustache HTML template to use to render the questions.
*/
QuestionsWidget.prototype.showQuestions = function(template, view, category) {
var self = this, html;
// Construct the table HTML from the template and an element from the HTML
html = Mustache.to_html(template, view);
// Replace the current questions table with the new one and update our reference to it
self._questionsForm.html( html );
// Setup validation on the questions table element
self._setupQuestionValidation(self._questionsForm, category);
// Setup question dependency information
self._setupQuestionDependencies(category);
// Apply zebra striping classes to the table
$("table", self._questionsForm).addClass("questionsTable styledTable");
$("tr:not('.dependency-row'):odd", self._questionsForm).addClass("odd");
$("tr:not('.dependency-row'):even", self._questionsForm).addClass("even");
self._questionsTemplateLoaded = true;
// Prevent the user from performing data entry until we know that the responses are in.
self._disableResponses();
// Show the responses if they've already come in.
self._showResponses();
// Initialize date pickers - if we have no DATE fields, this will do nothing
$("input.datepicker", self._questionsForm).datetimepicker();
// Initialize file inputs
$(".fileQuestionContainer button[name='upload']").click($.proxy(self._uploadFile, self));
$(".fileQuestionContainer button[name='delete']").click($.proxy(self._deleteFile, self));
};
/**
* Flatten the question group structure for ready application to the mustache view.
*/
QuestionsWidget.prototype.buildViewFromQuestionGroups = function(questionGroups, optionTypeResponse) {
var self = this, view;
view = {};
$.each(questionGroups, function(i, questionGroup) {
view["QG" + questionGroup.id] = questionGroup.displayText;
$.each(questionGroup.questions, function(j, question) {
var responses;
responses = self._renderType(question, optionTypeResponse);
if (!question.maxResponse || question.maxResponse === 1) {
view["Q" + question.id] = responses[0];
} else {
$.each(responses, function(sequence, response) {
view["Q" + question.id + "-" + sequence] = response;
});
}
});
if (questionGroup.questionGroups) {
$.extend(true, view, self.buildViewFromQuestionGroups(questionGroup.questionGroups, optionTypeResponse));
}
});
return view;
};
/**
* Render a question type as an HTML element.
*
* @param question the question object.
* @param optionTypeResponse the option details for rendering select lists.
* @returns {String} The resultant html for the element.
*/
QuestionsWidget.prototype._renderType = function(question, optionTypeResponse) {
var self = this, attributes, classes, filteredOptionTypeList, i, maxResponse, optionResponse, optionTypeList, questionName, response, responses;
// Define a function to build options.
function buildOptions(optionTypeIndex, optionType) {
var optionResponse, optionChoiceList, sortedOptionChoiceList;
optionResponse = "";
optionChoiceList = optionTypeResponse.distinctAnswerOptions[optionType.classificationName].answerOptions;
// Wrap options in an 'option group' if we
// have more than one option type
if (filteredOptionTypeList.length > 1) {
optionResponse += '<optgroup label="'
+ $('<div/>')
.text(
optionType.classificationName)
.html() + '">';
}
// Sort the answers by displayOrder
sortedOptionChoiceList = optionChoiceList
.slice(0);
sortedOptionChoiceList.sort(function(a, b) {
return a.displayOrder - b.displayOrder;
});
// Render each answer within the selected
// answertype
$.each(sortedOptionChoiceList, function(
optionIndex, option) {
// For each answer within the answer
// type: If the answer isn't active, don't
// process (i.e. output) it
if (option.active) {
// Render an indented option
optionResponse += '<option value="'
+ option.answerName + '">'
+ option.answerText
+ '</option>';
}
});
// Close option group (if we have >1 option
// type)
if (filteredOptionTypeList.length > 1) {
optionResponse += '</optgroup>';
}
return optionResponse;
}
// Init our bag of responses. These will be keyed by sequence number.
responses = {};
// If this type isn't active, don't process this node
if ( question.type.active ) {
// Given that we can have multiple responses (and therefore
// questions) construct any reasonably expensive in advance.
if (question.type.name === 'SET') {
// Retrieve option type list
optionTypeList = optionTypeResponse.optionTypeMap[question.id];
// Use injected option filter to limit option choices by various
// criteria
filteredOptionTypeList = $.grep(optionTypeList, self.optionFilter);
// If we found a matching answer type definition:
optionResponse = "";
$.each(filteredOptionTypeList, function(optionTypeIndex, optionType) {
optionResponse += buildOptions(optionTypeIndex, optionType);
});
}
// If we have just one response then it is unlikely that
// maxResponse gets set.
maxResponse = question.maxResponse;
if (!maxResponse) {
maxResponse = 1;
}
// For each response that can be associated with a question
// (generally just one), render it.
for (i = 0; i < maxResponse; ++i) {
// Determine the question name. Typically just the
// question id, although it can be appended with a
// sequence number in the case where there can be more
// than one response.
questionName = "Q" + question.id + (maxResponse > 1? "-" + i : "");
// Determine attributes, tooltip and classes to use for input
// element
attributes = 'name="' + questionName + '" ';
classes = 'questionInput ';
// If we have a 'displayInstructionText' property, output it as a
// tooltip using the title attribute.
if (question.displayInstructionText) {
attributes += 'title="' + question.displayInstructionText
+ '" ';
}
if (self._readOnly) {
// No inputs, just text
if (question.type.name==="BOOLEAN") {
response = '<span ' + attributes + '>' + _("No") + "</span>";
} else if (question.type.name==="DATE") {
response = '<span ' + attributes + ' class="datepicker" />';
} else {
response = '<span ' + attributes + '/>';
}
} else {
// Normal interactive mode
switch (question.type.name) {
case 'BOOLEAN':
response = '<input type="checkbox" value="" ' + attributes
+ 'class="' + classes + '" />';
break;
case 'TEXT':
response = '<input type="text" value="" ' + attributes
+ 'class="' + classes + '" />';
break;
case 'NUMERIC':
response = '<input type="number" value="" ' + attributes
+ 'class="' + classes + '" />';
break;
case 'DATE':
classes += 'datepicker ';
response = '<input type="text" value="" ' + attributes
+ 'class="' + classes + '" />';
break;
case 'FILE':
classes += 'fileDisplay ui-state-disabled ';
// File uploads require a multi-part question which contains the
// file input element, a text element to hold and display the value
// (because we can't get write access to the file input), and a
// progress area.
response = '<div class="fileQuestionContainer">'
+ ' <form method="POST" target="frame' + questionName + '" enctype="multipart/form-data">'
+ ' <div class="fileChooser">'
+ ' <input type="file" class="fileInput" name="file" />'
+ ' <button name="upload">Upload</button>'
+ ' </div>'
+ ' <input type="text" disabled="disabled" value="" ' + attributes + ' class="' + classes + ' fileChosen" style="display: none;" />'
+ ' <button name="delete" class="fileChosen" style="display: none;">Delete</button>'
+ ' <div class="fileProgress" style="display: none;">'
+ ' <img alt="progress" src="images/ajax-loader.gif" style="padding: 4px 0 0 4px;">'
+ ' <span style="padding-left: 1em; position: relative; bottom: 4px;">Uploading file...</span>'
+ ' </div>'
+ ' </form>'
+ ' <iframe name="frame' + questionName + '" width="0" height="0" style="display: none;" />'
+ ' </div>';
break;
case 'SET':
// Build the select list of options
response = '<select ' + attributes + 'class="' + classes + '">';
// Provide a blank entry (i.e. - no selection has yet been made)
response += '<option value="">'
+ QuestionsWidget.prototype._SELECT_TEXT + '</option>';
response += optionResponse;
response += '</select>';
break;
default:
response = '<!-- UNKNOWN DATA TYPE: ' + question.type.name
+ ' -->';
break;
}
}
responses[i] = response;
}
} else {
responses[0] = "";
}
return responses;
};
/**
* Setup validation rules associated with the input elements on the form.
*
* @param questionsFormElement
* The element of the question form which contains the input elements
* @param category
* The category data
*/
QuestionsWidget.prototype._setupQuestionValidation = function(questionsFormElement, category) {
var questionJQueryObjects, ruleName, ruleData, ruleIndex = 0, self = this;
function applyValidationToQuestion(question, category, questionGroupMandatory) {
var maxLengthRule, regexRule, requiredRule;
// Get the jquery object of the question
questionJQueryObjects = self._getQuestionElements(question.id);
// Setup the 'onchange' event hooks for this element
questionJQueryObjects.change($.proxy(self.validateResponseChanged, self));
// If the question or questionGroup is mandatory, add a 'required' validation rule
if (question.mandatory || questionGroupMandatory) {
// Setup the validation rule for required fields
requiredRule = $.fn.validateElement('getRule', 'required');
// Localize the validation failure message
requiredRule = $.extend(requiredRule, {message:_(requiredRule.message)});
questionJQueryObjects.validateElement('addRule', requiredRule);
}
// If we have any regex rules - validationRuleIds - apply them here
if (question.validationRuleIds && question.validationRuleIds.length>0) {
// For each validation rule ID
$.each(question.validationRuleIds, function(ruleIdIndex, ruleId) {
// Loop through validation rules
$.each(category.validationRules, function(ruleIndex,rule) {
// If we locate the correct (and active) rule:
if (rule.active && ruleId===rule.id) {
// Setup a new regex rule for this rule ID
regexRule = $.fn.validateElement('getRule', 'regex');
// Set the regular expression value and message
regexRule = $.extend(regexRule, {regularExpression:rule.ruleExpression});
regexRule = $.extend(regexRule, {message:_(rule.messageText)});
regexRule = $.extend(regexRule, {warning:_(rule.warning)});
questionJQueryObjects.validateElement('addRule', regexRule);
}
});
});
}
}
// Recurse through question groups, feeding leaf nodes (i.e. questions) into the
// validation setup function as appropriate.
function applyValidationToQuestionGroups(questionGroups, category) {
$.each(questionGroups, function(i, questionGroup) {
$.each(questionGroup.questions, function(j, question) {
applyValidationToQuestion(question, category, questionGroup.mandatory);
});
if (questionGroup.questionGroups) {
applyValidationToQuestionGroups(questionGroup.questionGroups, category);
}
});
}
// Start recursive question validation crawl if we're not in read-only or locked modes
if (!self._readOnly && !self._locked) {
applyValidationToQuestionGroups(category.questionGroups, category);
}
};
/**
* Setup question dependency information so we can re-evaluate whether to show
* dependent questions or not as the user alters responses.
*
* Fills _questionDependencies variable with this structure:
* [
* { questionId: 1,
* questionContainer : [element to show/hide],
* dependsOnQuestions: [ { precedingQuestionId: 1,
* answerMatchRequired: false,
* precedingQuestionAnswer: null },
* ... ]
* },
* ...
* ]
*
* @param category The category data
*/
QuestionsWidget.prototype._setupQuestionDependencies = function(category) {
var self = this, questionDependencies;
/**
* Get question dependencies from a question, if they exist.
*
* @param question The question to retrieve dependencies from.
*/
function getQuestionDependenciesFromQuestion(question) {
var dependencyElement, questionContainerElement, questionDependencies;
// Return an empty array if we have no dependencies
questionDependencies = [];
if (question.dependsOnQuestions.length>0) {
// Setup onChange event hooks to all questions depended on
$.each(question.dependsOnQuestions, function(i, dependency) {
dependencyElement = self._getQuestionElements(dependency.precedingQuestionId).first();
// Only do this once per element; flag the element once we're done
if (!dependencyElement.attr('dependencyTrigger')) {
dependencyElement.attr('dependencyTrigger','true');
dependencyElement.change($.proxy(self._evaluateQuestionDependencies, self));
}
});
// Create and return a dependency information array
questionContainerElement = self._getQuestionElements(question.id).first();
questionDependencies = [{
questionId: question.id,
questionContainer : questionContainerElement.closest('.dependency-container')[0],
// Copy the array using slice(0)
dependsOnQuestions: question.dependsOnQuestions.slice(0)
}];
}
return questionDependencies;
}
/**
* Get question dependencies in a flattened list from the question group.
* Recurses into sub-groups.
*
* @param questionGroup The question group to retrieve dependencies from.
*/
function getQuestionDependenciesFromQuestionGroups(questionGroups) {
var questionDependencies = [];
$.each(questionGroups, function(i, questionGroup) {
// Get all question dependencies from the questions in this group
$.each(questionGroup.questions, function(j, question) {
questionDependencies = $.merge(questionDependencies, getQuestionDependenciesFromQuestion(question));
});
// If we have any sub-groups, recurse into them to get all questions
if (questionGroup.questionGroups) {
questionDependencies = $.merge(questionDependencies, getQuestionDependenciesFromQuestionGroups(questionGroup.questionGroups));
}
});
// Return flattened list of question dependencies
return questionDependencies;
}
// Store the flattened list of question dependencies
self._questionDependencies = getQuestionDependenciesFromQuestionGroups(category.questionGroups);
// Update the form and disable dependent elements, in case the responses take
// a long time to return we leave the form in a correct state when no responses
// are present.
self._evaluateQuestionDependencies();
};
/**
* Evaluate the dependent question info and hide/show dependent questions
* as necessary.
*/
QuestionsWidget.prototype._evaluateQuestionDependencies = function() {
var self = this, dependencies, dependenciesSatisfied, dependentElement, re;
// If we don't have any dependencies - i.e., if we're called during setup - no-op
if (!self._questionDependencies) {
return;
}
// Iterate over all dependent questions
$.each(self._questionDependencies, function(i, questionDependency) {
dependenciesSatisfied = false;
// Check dependency requirements for the question
$.each(questionDependency.dependsOnQuestions, function(j, dependency) {
dependentElement = self._getQuestionElements(dependency.precedingQuestionId).first();
if (self._readOnly) {
// Read-only mode
if (dependency.answerMatchRequired) {
// Does answer match the pattern?
re = new RegExp(dependency.precedingQuestionAnswer);
if (re.test(dependentElement.text())) {
dependenciesSatisfied = true;
}
} else {
// Any value is acceptable
if (dependentElement.text().length>0) {
dependenciesSatisfied = true;
}
}
} else {
// Normal interactive mode
if (dependency.answerMatchRequired) {
// Does answer match the pattern?
re = new RegExp(dependency.precedingQuestionAnswer);
if (dependentElement[0].nodeName === "INPUT" && dependentElement.prop("type") === "checkbox") {
// Checkbox
if (dependentElement.prop('checked')) {
if (re.test(dependentElement.val())) {
dependenciesSatisfied = true;
}
}
} else {
// Text and select
if (re.test(dependentElement.val())) {
dependenciesSatisfied = true;
}
}
} else {
// Any value is acceptable
if (dependentElement[0].nodeName === "INPUT" && dependentElement.prop("type") === "checkbox") {
// Checkbox
if (dependentElement.prop("checked")) {
dependenciesSatisfied = true;
}
} else {
// Text and select
if (dependentElement.val() && dependentElement.val().length > 0) {
dependenciesSatisfied = true;
}
}
}
}
});
if (dependenciesSatisfied) {
// If requirements are met:
// Enable the row and input(s) (visual feedback)
$(questionDependency.questionContainer).removeClass('ui-state-disabled', "slow");
$("input, select", questionDependency.questionContainer).removeClass('ui-state-disabled');
// Enable the form inputs
$("input, select", questionDependency.questionContainer).removeAttr('disabled');
} else {
// If requirements are not met:
// Disable the row and input(s) (visual feedback)
$(questionDependency.questionContainer).addClass('ui-state-disabled', "slow");
$("input, select", questionDependency.questionContainer).addClass('ui-state-disabled');
// Disable the form inputs
$("input, select", questionDependency.questionContainer).attr('disabled', 'disabled');
}
});
};
/**
* Find a question element either by its id directly, or all of a sequence
* relating to the question.
*/
QuestionsWidget.prototype._getQuestionElements = function(questionId) {
var self = this, element;
element = $("[name='Q" + questionId + "']", self._questionsForm);
if (element.length === 0) {
// Can't find an exact match so now we look for a match on
// the questionId for a repeating response.
element = $("[name^='Q" + questionId + "-']", self._questionsForm);
}
return element;
};
/**
* Clear responses.
*/
QuestionsWidget.prototype.clearResponses = function() {
var self = this;
self._responses = null;
if (self._questionsTemplateLoaded) {
self._disableResponses();
}
};
/**
* Delete the file associated with this question. Starts the deletion process
* by updating the UI to show progress, and sends a changed response to the
* response service.
* When a response is received, the deleteResponseReceived method will be
* called via a responseCallback.
*
* @param event The event object for the button click.
*/
QuestionsWidget.prototype._deleteFile = function(event) {
var self = this, questionIdentifiers, responseCallback;
// Set the file chooser to an empty string; this will in turn fire the
// changed response method and propogate the empty value to the service.
$(event.target).closest('.fileQuestionContainer').find('.fileDisplay').val("")
.change();
// Determine question identifiers
questionIdentifiers = self._getQuestionIdentifiers(
$(event.target).closest('.fileQuestionContainer').find('.fileDisplay').attr('name')
);
// Setup response callback for when we receive the response
responseCallback = $.proxy(self.deleteResponseReceived, self);
if (!self._responseCallbacks[questionIdentifiers.questionId]) {
self._responseCallbacks[questionIdentifiers.questionId] = [];
}
self._responseCallbacks[questionIdentifiers.questionId][questionIdentifiers.sequenceId] = responseCallback;
// Stop further event processing
return false;
};
/**
* Continue with the deletion process; we now have a successful
* response to our deletion response, so update the UI to reflect
* this.
* This function is called from a callback setup in _deleteFile()
*
* @param response The response data for the affected question.
*/
QuestionsWidget.prototype.deleteResponseReceived = function(response) {
var self = this, questionElement, questionName;
// Locate question element
questionName = "Q" + response.questionId;
if (response.sequenceId>0) {
questionName += "-" + response.sequenceId;
}
questionElement = $("[name='" + questionName + "']");
// Update the UI to show the 'browse for file' input
questionElement.closest('.fileQuestionContainer').find('.fileChooser').fadeIn().show();
questionElement.closest('.fileQuestionContainer').find('.fileChosen').fadeOut().hide();
// Clear the file input
questionElement.closest('.fileQuestionContainer').find('form')[0].reset();
};
/**
* Begin the file upload process. Send a saveResponse call and then
* wait for the responseId we get in the response before continuing
* in the uploadResponseReceived() function.
*
* @param event The event object for the button click.
*/
QuestionsWidget.prototype._uploadFile = function(event) {
var self = this, questionIdentifiers, responseCallback;
// Store reference to uploading element
self._uploadingQuestion = $(event.target).closest('.fileQuestionContainer').find('.fileDisplay');
// If there is no chosen file, don't attempt to submit the form
if (!self._uploadingQuestion.closest('.fileQuestionContainer').find('.fileInput').val()) {
return false;
}
// Determine question identifiers
questionIdentifiers = self._getQuestionIdentifiers(self._uploadingQuestion.attr('name'));
// Setup response callback for when we receive the response
responseCallback = $.proxy(self.uploadResponseReceived, self);
if (!self._responseCallbacks[questionIdentifiers.questionId]) {
self._responseCallbacks[questionIdentifiers.questionId] = [];
}
self._responseCallbacks[questionIdentifiers.questionId][questionIdentifiers.sequenceId] = responseCallback;
// Update the fileDisplay input from the fileInput input and trigger the onChange event
$(self._uploadingQuestion).val(
$(self._uploadingQuestion).closest('.fileQuestionContainer').find('.fileInput').val()
).change();
// Return false to stop further event processing
return false;
};
/**
* Continue the file upload process from the callback setup in
* _uploadFile(). We have a successfully saved response so upload
* the file in a hidden iframe and setup the next stage which will
* be triggered by an onLoad event in the hidden iframe.
*
* @param response The response data for the affected question.
*/
QuestionsWidget.prototype.uploadResponseReceived = function(response) {
var self = this, uploadForm, uploadIframe;
// Update UI to reflect upload in progress
$(self._uploadingQuestion).closest('.fileQuestionContainer').find('.fileChooser').fadeOut().hide();
$(self._uploadingQuestion).closest('.fileQuestionContainer').find('.fileProgress').fadeIn().show();
// Setup upload response iframe which will callback to
// the uploadComplete function on completion
uploadIframe = $(self._uploadingQuestion).closest('.fileQuestionContainer').find('[name^=frame]');
uploadIframe.load($.proxy(self.uploadComplete, self));
// Launch upload POST to blank iframe
uploadForm = $(self._uploadingQuestion).closest('.fileQuestionContainer').find('form');
uploadForm.attr('action', self._responseAttachmentUrl + "/" + response.responseId);
uploadForm.submit();
};
/**
* The file upload process is now complete; update the UI to reflect this.
* This function is called by the onLoad event set on the hidden iframe
* in the uploadResponseReceived() function.
*/
QuestionsWidget.prototype.uploadComplete = function() {
var self = this;
// Update progress indicator
$(self._uploadingQuestion).closest('.fileQuestionContainer').find('.fileProgress').fadeOut().hide();
// Update UI to reflect upload complete
$(self._uploadingQuestion).closest('.fileQuestionContainer').find('.fileChosen').fadeIn().show();
};
/**
* Disable the questions which we know are loaded at this time.
*/
QuestionsWidget.prototype._disableResponses = function() {
var self = this;
$("[name^='Q']", self._questionsForm).each(
function() {
var questionElem, questionElemType, questionName;
questionElem = $(this);
questionElemType = questionElem.attr("type");
if (questionElemType
&& questionElemType.toLowerCase() === "checkbox") {
questionElem.attr("checked", false);
} else if (questionElem[0].nodeName === "SELECT") {
questionElem.val("");
} else {
// Text input type
questionElem.val("");
}
// Disable all the questions until we have loaded
// any previous responses, at which time we decide
// which ones to re-enable
if (questionElem[0].nodeName === "SELECT" || questionElem[0].nodeName === "INPUT") {
questionElem.attr("disabled", "disabled");
}
if (questionElem.hasClass('fileDisplay')) {
questionElem.closest('.fileQuestionContainer').addClass("ui-state-disabled");
questionElem.closest('.fileQuestionContainer').find('input[type=file]').addClass("ui-state-disabled").attr('disabled', 'disabled');
questionElem.closest('.fileQuestionContainer').find('button').addClass("ui-state-disabled");
}
questionElem.addClass("ui-state-disabled");
});
};
/**
* Show that questions are unable to be retrieved.
*
* @param reason
* the reason they cannot.
*/
QuestionsWidget.prototype.showFailure = function(reason) {
var self = this;
self._progressInd.fadeOut().hide();
self.showData(null);
jGrowl.showErrorMessage(reason);
};
/**
* Set the responses to the questions given a map of question ids to response
* data. In addition we clear and disable any questions that are not referred to
* by the responses in order to render only what is common.
* @param responses the responses.
*/
QuestionsWidget.prototype.setResponses = function(responses) {
var self = this;
self._responses = responses;
// Set the form elements with the response values
self._showResponses();
};
/**
* Update our internal representation of responses with their latest values as
* represented externally. This will update the behaviour of
* getChangedResponses. This is typically used by the caller to reflect the
* current state of the responses that have been persisted. It is almost like
* having retrieved all the responses again and calling setResponses but there
* is no update to the UI. We don't want to mutate anything that the user has
* already entered.
*
* @param responses
* the responses that need to be updated.
*/
QuestionsWidget.prototype.updateResponses = function(responses) {
var self = this, callbackResponse;
$.each(responses, function(questionId, responseResponses) {
$.each(responseResponses, function(sequenceId, response) {
if (!self._responses[questionId]) {
self._responses[questionId] = {};
}
self._responses[questionId][sequenceId]= response;
// If we have a response callback waiting, trigger and remove it
if (self._responseCallbacks[questionId] && self._responseCallbacks[questionId][sequenceId]) {
// Update the response object we store to retain question and sequence
callbackResponse = {};
$.extend(callbackResponse, response);
callbackResponse.questionId = questionId;
callbackResponse.sequenceId = sequenceId;
self._responseCallbacks[questionId][sequenceId](callbackResponse);
self._responseCallbacks[questionId][sequenceId] = null;
}
});
});
};
/**
* Show the responses if we have them.
*/
QuestionsWidget.prototype._showResponses = function() {
var self = this;
if (self._questionsTemplateLoaded && self._responses) {
$("[name^='Q']", self._questionsForm).each(function() {
var changeHandlers, questionElem, questionEnabled, questionIdentifiers, questionName, response, responseDate, responses;
questionElem = $(this);
questionName = questionElem.attr("name");
// Enable a question if it intersects
// with the others or it isn't known to
// the current set of responses.
questionIdentifiers = self._getQuestionIdentifiers(questionName);
responses = self._responses[questionIdentifiers.questionId];
if (responses) {
response = responses[questionIdentifiers.sequenceId];
}
if (response) {
if (response.intersect) {
// We are writing the answer.
if (self._readOnly) {
// Read-only mode
if (typeof response.answer !== "boolean") {
if (questionElem.hasClass('datepicker')) {
// Convert ISO8601 to local format and set on span
responseDate = new Date();
responseDate.parseISO8601Time(response.answer);
questionElem.text(responseDate.toLocaleDateString());
} else {
// Set the value as text on the question span
questionElem.text(response.answer);
}
} else {
// Boolean
questionElem.text(_("Yes"));
}
} else {
// Normal interactive mode
if (typeof response.answer !== "boolean"){
if (questionElem.hasClass('datepicker')) {
// If it's a date text field:
responseDate = new Date();
responseDate.parseISO8601Time(response.answer);
// Store element's change handlers and remove the attached handlers
changeHandlers = questionElem.data("events").change;
delete questionElem.data("events").change;
// Set the date (which triggers a change event we don't want)
questionElem.datetimepicker('setDate', responseDate);
// Re-attach the change event handlers
questionElem.data("events").change = changeHandlers;
} else if (questionElem.hasClass("fileDisplay")) {
// File type
if (response.answer) {
questionElem.closest('.fileQuestionContainer').find('.fileChooser').hide();
questionElem.closest('.fileQuestionContainer').find('.fileChosen').show();
questionElem.val(response.answer);
}
} else {
// Otherwise, normal text or select
questionElem.val(response.answer);
}
} else if (response.answer) {
// Checkbox
questionElem.attr("checked", "checked");
}
}
questionEnabled = true;
} else {
questionEnabled = false;
}
} else {
questionEnabled = true;
}
// Enable the form elements now
if (questionEnabled) {
if (questionElem.hasClass('fileDisplay')) {
questionElem.closest('.fileQuestionContainer').removeClass("ui-state-disabled");
questionElem.closest('.fileQuestionContainer').find('input[type=file]').removeClass("ui-state-disabled").removeAttr('disabled');
questionElem.closest('.fileQuestionContainer').find('button').removeClass("ui-state-disabled");
} else {
// Question is enabled, remove the styles which present it as disabled
questionElem.removeClass("ui-state-disabled").show();
// If the widget isn't in read-only mode, remove the disabled attribute to
// allow users to interact with the field.
if (!self._locked) {
questionElem.removeAttr("disabled");
}
}
}
});
// Disable rows if all of their child elements are disabled.
$("tr", self._questionsForm).each(function() {
var trElem, trInputs, trInputsDisabled;
trElem = $(this);
trInputs = $("[name^='Q']", trElem);
trInputsDisabled = 0;
trInputs.each(function() {
var trInput;
trInput = $(this);
if (trInput.hasClass('fileDisplay')) {
if (trInput.closest('.fileQuestionContainer').hasClass("ui-state-disabled")) {
trInputsDisabled++;
}
} else {
if (trInput.attr("disabled") && trInput.hasClass("ui-state-disabled")) {
trInputsDisabled++;
}
}
});
if (trInputs.length === 0 || trInputsDisabled < trInputs.length) {
trElem.removeClass("ui-state-disabled");
} else {
trElem.addClass("ui-state-disabled");
}
});
}
// Show/hide multiple responses depending on whether they have values.
self._renderMultipleResponses();
// Update question dependencies
self._evaluateQuestionDependencies();
};
/**
* Retrieve the responses entered. New responses will not have an id property
* whereas existing responses will. Only responses that have changed or are new
* will be returned.
*
* @return responses with each response as its own array element. Also provides
* a "changed" property indicating whether the response has changed or
* it is a new response.
*/
QuestionsWidget.prototype.getChangedResponses = function() {
var self = this, answerDate, responses;
responses = {};
if (self._questionsTemplateLoaded && self._responses) {
$("[name^='Q']", self._questionsForm).each(function() {
var answer, existingResponse, existingResponses, questionElem, questionIdentifiers, questionName, newResponseReqd, responseResponses;
questionElem = $(this);
questionName = questionElem.attr("name");
answer = self._getAnswerFromElement(questionElem);
questionIdentifiers = self._getQuestionIdentifiers(questionName);
existingResponses = self._responses[questionIdentifiers.questionId];
if (existingResponses) {
existingResponse = existingResponses[questionIdentifiers.sequenceId];
}
if (existingResponse) {
if (existingResponse.intersect) {
// We have an intersect which means that this response could have changed.
newResponseReqd = (answer !== existingResponse.answer);
} else {
newResponseReqd = false;
}
} else {
// In the case of nothing having been entered before but the field is available
// for input, see if the text has changed or we have an option.
newResponseReqd = (answer !== null);
}
// Record the changed response for returning.
if (newResponseReqd) {
// See if we need to add to an existing
// set of responses that we're returning
// and start a bag if we haven't.
responseResponses = responses[questionIdentifiers.questionId];
if (!responseResponses) {
responseResponses = {};
responses[questionIdentifiers.questionId] = responseResponses;
}
// Append a new response to the set.
responseResponses[questionIdentifiers.sequenceId] = {
answer : answer,
changed : (existingResponse? true : false)
};
}
});
}
return responses;
};
/**
* Break apart a question name like 'Q3-0' and return a data
* structure with the identifiers like {questionId:3, sequenceId: 0}
*
* @param questionName The question name string, e.g. "Q3" or "Q4-2"
* @returns The question identifiers encapsulated in an object with
* structure {questionId: 3, sequenceId: 0}
*/
QuestionsWidget.prototype._getQuestionIdentifiers = function(questionName) {
var self = this, idParts, questionIdentifiers;
questionIdentifiers = {};
idParts = questionName.replace("Q","").split("-");
questionIdentifiers.questionId = parseInt(idParts[0], 10);
if (idParts.length === 1) {
// Default sequence ID is 0 if not available from questionName
questionIdentifiers.sequenceId = 0;
} else {
questionIdentifiers.sequenceId = parseInt(idParts[1], 10);
}
return questionIdentifiers;
};
/**
* This method is responsible for iterating over all questions that may have
* multiple answers. For each question id, it will traverse backward from the
* highest sequence and hide a question/sequence if there is no corresponding
* value. When the traversal reaches a question/sequence with a value then it
* will stop.
*/
QuestionsWidget.prototype._renderMultipleResponses = function() {
var self = this, canRemove, containerVisibilityMap, i, lastContainerElem, lastQuestionId, nextContainerElemId, previousAnswerForGroup, questionElems;
questionElems = [];
// Gather all of the question elements with a sequence.
$("[name^='Q']", self._questionsForm).filter(function() {
return ($(this).attr("name").indexOf("-") !== -1);
}).each(function() {
questionElems.push($(this));
});
// Sort all of the elements in reverse order as we will be culling elements
// with no answer from the back.
questionElems.sort(function(a, b) {
return (a.attr("name") > b.attr("name")? -1 : 1);
});
// Create functions to handle the adding and subtracting of rows.
function addResponseHandler() {
$(this).closest(".multiResponseControl").fadeOut("slow").hide();
$(this).closest(".dependency-container").next().fadeIn("slow").show();
return false;
}
function subtractResponseHandler() {
var containerElem;
containerElem = $(this).closest('.dependency-container');
$(".multiResponseControl", containerElem.prev()).fadeIn("slow").show();
$("[name^='Q']", containerElem).val("");
setTimeout(self.onResponseChanged, 0);
containerElem.fadeOut("slow").hide();
return false;
}
// Go through the questions (now reversed and hide those that do not
// contain an answer.
containerVisibilityMap = {};
nextContainerElemId = 0;
lastQuestionId = null;
lastContainerElem = null;
$.each(questionElems, function(i, questionElem) {
var addControlElem, containerElem, containerElemId, containerVisibilityMapValue, questionId, questionIdentifiers, questionName, questionVisible, multipleResponseControlElem, subtractControlElem;
// Determine the question id of the question we're dealing with.
questionName = questionElem.attr("name");
questionIdentifiers = self._getQuestionIdentifiers(questionName);
questionId = questionIdentifiers.questionId;
// If we have a new question the reset a couple of things.
if (questionId !== lastQuestionId) {
previousAnswerForGroup = false;
lastQuestionId = questionId;
}
// Get our nearest ancestor container so that we can
// determine multiple response control functionality.
containerElem = questionElem.closest('.dependency-container');
if (containerElem !== lastContainerElem) {
multipleResponseControlElem = $(".multiResponseControl", containerElem);
addControlElem = $("button[name='add']", multipleResponseControlElem);
addControlElem.unbind("click.add").bind("click.add", addResponseHandler);
subtractControlElem = $("button[name='subtract']", multipleResponseControlElem);
subtractControlElem.unbind("click.subtract").bind("click.subtract", subtractResponseHandler);
// Determine if this question has the potential for
// being removed. If not then we'll next want to
// consider that it should always be displayed.
canRemove = (subtractControlElem.length > 0);
lastContainerElem = containerElem;
}
// Determine the visibility of this question
// element. If we've got an questionVisible previously
// for this question id then we can just assume
// that there is an questionVisible. Remember that we're
// traversing backwards through the sequence
// numbers also.
if (!previousAnswerForGroup && canRemove) {
questionVisible = (self._getAnswerFromElement(questionElem)? true : false);
if (questionVisible) {
previousAnswerForGroup = true;
}
} else {
questionVisible = true;
}
// We always require a container that
// shows/hides us and we may be grouped with other
// questions.
containerElemId = containerElem.containerElemId;
if (containerElemId === undefined) {
// We need to uniquely identify a container so we
// maintain our own ids (bring back C pointers!).
containerElemId = containerElem.containerElemId = nextContainerElemId++;
}
containerVisibilityMapValue = containerVisibilityMap[containerElemId];
if (containerVisibilityMapValue !== undefined) {
// We have seen this container before so if it isn't
// visible given some prior processing, we now have
// the opportunity of making it visible once again
// if the current element has a value.
if (!containerVisibilityMapValue.visible) {
containerVisibilityMapValue.visible = questionVisible;
}
} else {
// We've not seen this container element before so
// we record its visibility.
containerVisibilityMap[containerElemId] = {
element : containerElem,
visible : questionVisible
};
}
});
// We now iterate through all of the container elements and show/hide them
// (and thus their children) depending on whether any child element answers
// are available.
$.each(containerVisibilityMap, function(containerElemId, containerVisibilityMapValue) {
var nextDependencyContainer;
if (containerVisibilityMapValue.visible) {
containerVisibilityMapValue.element.fadeIn("slow").show();
// If we have an adjancent dependency container and it
// is visible then we need to ensure that any
// multiResponseControl is hidden as they only require
// display when they can actually do something.
nextDependencyContainer = containerVisibilityMapValue.element
.next(".dependency-container:visible");
if (nextDependencyContainer.length > 0) {
$(".multiResponseControl", containerVisibilityMapValue.element).hide();
}
} else {
containerVisibilityMapValue.element.fadeOut("slow").hide();
}
});
};
/**
* Determine an answer given various types of html element.
*/
QuestionsWidget.prototype._getAnswerFromElement = function(questionElem) {
var self = this, answer, answerDate, questionElemType;
// An element can either be an input or select from our perspective.
if (questionElem[0].nodeName === "INPUT") {
// Inputs can use the value field unless they're
// a checkbox in which case we look for that
// attr.
questionElemType = questionElem.attr("type").toLowerCase();
if (questionElemType === "checkbox") {
// Checkbox
answer = (questionElem.attr("checked")? true : false);
} else if (questionElemType === "number") {
// Input:number
answer = parseFloat(questionElem.val(), 10);
} else if (questionElem.hasClass('datepicker')) {
// Date picker
answerDate = questionElem.datetimepicker('getDate');
if (answerDate) {
answer = answerDate.formatISO8601Time();
}
} else {
// Standard text input
answer = questionElem.val();
}
} else {
// Must be a select. If nothing has been selected then show nothing.
answer = questionElem.val();
}
// Return the value or null if we've determined that there isn't one.
return (self._hasAnswer(answer)? answer : null);
};
/**
* Check an answer depending on its type and determine if it
* has a real value or not.
*
* @returns True if an answer has a value, according to type.
*/
QuestionsWidget.prototype._hasAnswer = function(answer) {
return ((typeof answer === "string" && answer !== "") ||
(typeof answer === "number" && !isNaN(answer)) ||
(typeof answer === "boolean" && answer));
};
h1. Usage
h2. Binding goals
The common method of usage is to simply declare the plugin and its goals. Associate the {{minify-js}} and {{minify-css}} goals with the (bound by default) {{prepare-package}} phase to have the plugin invoked, like so:
{code}
<project>
...
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>webminifier-maven-plugin</artifactId>
<version>${projectVersion}</version>
<executions>
<execution>
<goals>
<goal>minify-js</goal>
<goal>minify-css</goal>
</goals>
</execution>
</executions>
</plugin>
...
</plugins>
...
</build>
...
</project>
{code}
h2. Configuration
Configuration options can be passed to the plugin using a {{configuration}} section, like so:
{code}
...
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>webminifier-maven-plugin</artifactId>
...
<configuration>
<jsSplitPoints>framework.js</jsSplitPoints>
<yuiJSObfuscate>false</yuiJSObfuscate>
</configuration>
</plugin>
...
{code}
A list of all configuration options follows:
||Configuration Option||Required||Default Value||Description||
|sourceFolder|Y|target/classes|The folder to copy and apply minification to|
|destinationFolder|Y|target/min/classes|The folder to save the minified version of the files to|
|htmlFiles|Y|\*\*/\*.htm,\*\*/\*.html|Attempt to minify resources linked to from HTML files matching this filename pattern|
|jsSplitPoints|N| |Files matching a filename pattern supplied here will be used as JavaScript split points|
|cssSplitPoints|N| |Files matching a filename pattern supplied here will be used as CSS split points|
The following are options specific to the YUI compressor:
||Configuration Option||Required||Default Value||Description||
|yuiJSObfuscate|N|true|Obfuscate as well as minify the JavaScript files|
|yuiJSPreserveSemicolons|N|false|Preserve all semicolons|
|yuiJSDisableOptimizations|N|false|Disable all micro optimizations|
h2. Example
Given the following plugin configuration:
{code}
...
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>webminifier-maven-plugin</artifactId>
<version>${projectVersion}</version>
<executions>
<execution>
<goals>
<goal>minify-js</goal>
<goal>minify-css</goal>
</goals>
</execution>
</executions>
<configuration>
<jsSplitPoints>framework.js</jsSplitPoints>
</configuration>
</plugin>
...
{code}
And the following directory structure before minifcation:
- app.html
- utilities.js
- framework.js
- ui.js
- application.js
- framework.css
- ui.css
- application.css
With the following references in the file target/min/classes/app.html:
{code}
...
<script type="text/javascript" src="utilities.js"></script>
<script type="text/javascript" src="framework.js"></script>
<script type="text/javascript" src="ui.js"></script>
<script type="text/javascript" src="application.js"></script>
...
<link rel="stylesheet" type="text/css" href="framework.css" />
<link rel="stylesheet" type="text/css" href="ui.css" />
<link rel="stylesheet" type="text/css" href="application.css" />
...
{code}
You'd see the following files in target/min/classes after minification:
- app.html
- 1.js (containing utilities.js and framework.js)
- 2.js (containing ui.js and application.js)
- 1.css (containing framework.css, ui.css and application.css)
And the references in target/min/classes/app.html would be re-written like:
{code}
...
<script type="text/javascript" src="1.js"></script>
<script type="text/javascript" src="2.js"></script>
...
<link rel="stylesheet" type="text/css" href="1.css" />
...
{code}
/*global $, QUnit, Filter*/
/**
* Tests for the src/main/widget/Filter.js object
*/
var equals = QUnit.equals;
var expect = QUnit.expect;
var ok = QUnit.ok;
var test = QUnit.test;
test("testFilter", function(){
var filter = new Filter({}), filter2 = new Filter({});
filter.addFilter("column1", { filter : function(item){return (item.somevar === true);}});
ok(!filter.filter({somevar : false}));
ok(filter.filter({somevar : true}));
ok(filter.getFilterById("column1") !== undefined);
ok(filter2.getFilterById("column1") === undefined);
});
package org.codehaus.mojo.webminifier;
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import javax.xml.transform.TransformerException;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.codehaus.plexus.util.DirectoryScanner;
import org.xml.sax.SAXException;
import com.yahoo.platform.yui.compressor.JavaScriptCompressor;
/**
* Mojo to invoke WebMinifier plugin to minify web files.
*
* @author Ben Jones
* @goal minify-js
* @phase prepare-package
*/
public class WebMinifierMojo
extends AbstractMojo
{
/**
* The source folder with un-minified files.
*
* @parameter default-value="${project.build.directory}/classes"
* @required
*/
private File sourceFolder;
/**
* The output folder to write minified files to.
*
* @parameter default-value="${project.build.directory}/min/classes"
*/
private File destinationFolder;
/**
* Process HTML files which match these patterns.
*
* @parameter
*/
private List<String> htmlIncludes;
/**
* Do not process HTML files which match these patterns.
*
* @parameter
*/
private List<String> htmlExcludes;
/**
* If a JavaScript resource matching one of these file names is found while minifying it will be the last script
* file appended to the current minified script file. A new minified script will be created for the next file, if
* one exists.
*
* @parameter
*/
private List<String> jsSplitPoints;
/**
* All HTML, JavaScript and CSS files are assumed to have this encoding. Ê
*
* @parameter expression="${encoding}" default-value="${project.build.sourceEncoding}"
*/
private String encoding;
/**
* YUI option 'linebreak'; insert a linebreak after VALUE columnns.
*
* @parameter default-value="-1"
*/
private int yuiLinebreak;
/**
* YUI option 'disableOptimizations'; disable all micro-optimizations.
*
* @parameter default-value="false"
*/
private boolean yuiDisableOptimizations;
/**
* YUI option 'munge'; minify and obfuscate. If false, minify only.
*
* @parameter default-value="true"
*/
private boolean yuiMunge;
/**
* YUI option 'preserveSemi'; preserve semicolons before }.
*
* @parameter default-value="false"
*/
private boolean yuiPreserveSemi;
/**
* Concatenate two files.
*
* @param inputFile the file to concatenated.
* @param outputFile the file to be concatenated.
* @throws IOException if there is a problem with the operation.
*/
private void concatenateFile( File inputFile, File outputFile )
throws IOException
{
InputStream is = new FileInputStream( inputFile );
try
{
OutputStream os = new FileOutputStream( outputFile, true );
try
{
IOUtils.copy( is, os );
}
finally
{
os.close();
}
}
finally
{
is.close();
}
}
/**
* Main entry point for the MOJO.
*
* @throws MojoExecutionException if there's a problem in the normal course of execution.
* @throws MojoFailureException if there's a problem with the MOJO itself.
*/
public void execute()
throws MojoExecutionException, MojoFailureException
{
// Start off by copying all files over. We'll ultimately remove the js files that we don't need from there, and
// create new ones in there (same goes for css files and anything else we minify).
try
{
FileUtils.copyDirectory( sourceFolder, destinationFolder );
}
catch ( IOException e )
{
throw new MojoExecutionException( "Cannot copy file to target folder", e );
}
// Process each HTML source file and concatenate into unminified output scripts
int minifiedCounter = 0;
for ( String targetHTMLFile : getArrayOfTargetHTMLFiles() )
{
File targetHTML = new File( destinationFolder, targetHTMLFile );
// Parse HTML file and locate SCRIPT elements
DocumentResourceReplacer replacer;
try
{
replacer = new DocumentResourceReplacer( targetHTML );
}
catch ( SAXException e )
{
throw new MojoExecutionException( "Problem reading html document", e );
}
catch ( IOException e )
{
throw new MojoExecutionException( "Problem opening html document", e );
}
List<File> jsResources = replacer.findJSResources();
if ( jsSplitPoints == null )
{
jsSplitPoints = Collections.emptyList();
}
Set<File> concatenatedJsResources = new LinkedHashSet<File>( jsResources.size() );
File concatenatedJsResource = null;
for ( File jsResource : jsResources )
{
// If split point was reached don't write any more minified
// scripts to the current output file
if ( concatenatedJsResource == null || jsSplitPoints.contains( jsResource.getName() ) )
{
// Construct file path reference to unminified concatenated JS
StringBuilder concatenatedJsResourceFilePath = new StringBuilder();
concatenatedJsResourceFilePath.append( destinationFolder + File.separator );
concatenatedJsResourceFilePath.append( Integer.valueOf( ++minifiedCounter ) + ".js" );
concatenatedJsResource = new File( concatenatedJsResourceFilePath.toString() );
concatenatedJsResources.add( concatenatedJsResource );
}
// Concatenate input file onto output resource file
try
{
assert concatenatedJsResource != null;
concatenateFile( jsResource, concatenatedJsResource );
}
catch ( IOException e )
{
throw new MojoExecutionException( "Problem concatenating JS files", e );
}
// Finally, remove the JS resource from the target folder as it is no longer required (we've
// concatenated it).
jsResource.delete();
}
// Minify the concatenated JS resource files
Set<File> minifiedJSResources = new LinkedHashSet<File>( minifiedCounter );
for ( File concatenatedJSResource : concatenatedJsResources )
{
File minifiedJSResource;
try
{
minifiedJSResource = FileUtils.toFile( //
new URL( concatenatedJSResource.toURI().toString().replace( ".js", ".min.js" ) ) );
}
catch ( MalformedURLException e )
{
throw new MojoExecutionException( "Problem determining file URL", e );
}
minifiedJSResources.add( minifiedJSResource );
try
{
minifyJSFile( concatenatedJSResource, minifiedJSResource );
}
catch ( IOException e )
{
throw new MojoExecutionException( "Problem reading/writing JS", e );
}
logCompressionRatio( minifiedJSResource.getName(), concatenatedJSResource.length(),
minifiedJSResource.length() );
concatenatedJSResource.delete();
}
// Update source references
replacer.replaceJSResources( targetHTML, minifiedJSResources );
// Write HTML file to output dir
try
{
replacer.writeHTML( targetHTML, encoding );
}
catch ( TransformerException e )
{
throw new MojoExecutionException( "Problem transforming html", e );
}
catch ( IOException e )
{
throw new MojoExecutionException( "Problem writing html", e );
}
}
}
/**
* @return an array of html files to be processed.
*/
private String[] getArrayOfTargetHTMLFiles()
{
DirectoryScanner scanner = new DirectoryScanner();
scanner.setBasedir( destinationFolder );
String[] includesArray = getPatternsOrDefault( htmlIncludes, getDefaultIncludes() );
scanner.setIncludes( includesArray );
String[] excludesArray = getPatternsOrDefault( htmlExcludes, getDefaultExcludes() );
scanner.setExcludes( excludesArray );
scanner.scan();
String[] includedFiles = scanner.getIncludedFiles();
return includedFiles;
}
/**
* @return the list of excludes by default.
*/
protected String[] getDefaultExcludes()
{
return new String[0];
}
/**
* @return the list of includes by default.
*/
protected String[] getDefaultIncludes()
{
return new String[] { "**/*.html", "**/*.htm" };
}
/**
* @return property
*/
public File getDestinationFolder()
{
return destinationFolder;
}
/**
* @return property
*/
public String getEncoding()
{
return encoding;
}
/**
* @return property
*/
public List<String> getHtmlExcludes()
{
return htmlExcludes;
}
/**
* @return property
*/
public List<String> getHtmlIncludes()
{
return htmlIncludes;
}
/**
* @return property
*/
public List<String> getJsSplitPoints()
{
return jsSplitPoints;
}
private String[] getPatternsOrDefault( List<String> patterns, String[] defaultPatterns )
{
if ( patterns == null || patterns.isEmpty() )
{
return defaultPatterns;
}
else
{
return patterns.toArray( new String[patterns.size()] );
}
}
/**
* @return property
*/
public File getSourceFolder()
{
return sourceFolder;
}
/**
* @return property
*/
public int getYuiLinebreak()
{
return yuiLinebreak;
}
/**
* @return property
*/
public boolean isYuiDisableOptimizations()
{
return yuiDisableOptimizations;
}
/**
* @return property
*/
public boolean isYuiMunge()
{
return yuiMunge;
}
/**
* @return property
*/
public boolean isYuiPreserveSemi()
{
return yuiPreserveSemi;
}
private void logCompressionRatio( String filename, long original, long changed )
{
String percentageString;
if ( original > 0 )
{
int sizePercentage = (int) ( ( Double.valueOf( changed ) / Double.valueOf( original ) ) * 100.0 );
percentageString = sizePercentage + "%";
}
else
{
percentageString = "-";
}
getLog().info( filename + " minified from " + Long.valueOf( original ) + " to " + Long.valueOf( changed )
+ " bytes (" + percentageString + " of original size)" );
}
/**
* Perform the actual minification.
*
* @throws IOException a problem reading/writing files.
*/
private void minifyJSFile( File jsResource, File minifiedFile )
throws IOException
{
JavaScriptErrorReporter javaScriptErrorReporter = new JavaScriptErrorReporter( getLog() );
// Minify JS file and append to output JS file
InputStreamReader resourceReader =
new InputStreamReader( new BufferedInputStream( new FileInputStream( jsResource ) ), encoding );
try
{
OutputStreamWriter minifiedWriter = //
new OutputStreamWriter( new FileOutputStream( minifiedFile ), encoding );
try
{
// Setup JavaScriptCompressor and compress JS
JavaScriptCompressor compressor = new JavaScriptCompressor( resourceReader, javaScriptErrorReporter );
compressor.compress( minifiedWriter, yuiLinebreak, yuiMunge, false, yuiPreserveSemi,
yuiDisableOptimizations );
}
finally
{
minifiedWriter.close();
}
}
finally
{
resourceReader.close();
}
}
/**
* @param destinationFolder to set.
*/
public void setDestinationFolder( File destinationFolder )
{
this.destinationFolder = destinationFolder;
}
/**
* @param encoding to set.
*/
public void setEncoding( String encoding )
{
this.encoding = encoding;
}
/**
* @param htmlExcludes to set.
*/
public void setHtmlExcludes( List<String> htmlExcludes )
{
this.htmlExcludes = htmlExcludes;
}
/**
* @param htmlIncludes to set.
*/
public void setHtmlIncludes( List<String> htmlIncludes )
{
this.htmlIncludes = htmlIncludes;
}
/**
* @param jsSplitPoints to set.
*/
public void setJsSplitPoints( List<String> jsSplitPoints )
{
this.jsSplitPoints = jsSplitPoints;
}
/**
* @param sourceFolder to set.
*/
public void setSourceFolder( File sourceFolder )
{
this.sourceFolder = sourceFolder;
}
/**
* @param yuiDisableOptimizations to set.
*/
public void setYuiDisableOptimizations( boolean yuiDisableOptimizations )
{
this.yuiDisableOptimizations = yuiDisableOptimizations;
}
/**
* @param yuiLinebreak to set.
*/
public void setYuiLinebreak( int yuiLinebreak )
{
this.yuiLinebreak = yuiLinebreak;
}
/**
* @param yuiMunge to set.
*/
public void setYuiMunge( boolean yuiMunge )
{
this.yuiMunge = yuiMunge;
}
/**
* @param yuiPreserveSemi to set.
*/
public void setYuiPreserveSemi( boolean yuiPreserveSemi )
{
this.yuiPreserveSemi = yuiPreserveSemi;
}
}
package org.codehaus.mojo.webminifier;
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import java.io.File;
import java.net.URISyntaxException;
import org.apache.commons.io.FileUtils;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
/**
* Test the JSMinifierMojo class methods.
*
* @author huntc
*/
public class WebMinifierMojoTest
{
/**
* The mojo to test.
*/
private WebMinifierMojo mojo;
/**
* Set up a our mojo for testing.
*
* @throws URISyntaxException if something went wrong.
*/
@Before
public void setUpMojo()
throws URISyntaxException
{
File sourceFolder = ( new File( WebMinifierMojoTest.class.getResource( "a.html" ).toURI() ) ).getParentFile();
File destinationFolder = new File( System.getProperty( "java.io.tmpdir" ), "WebMinifierMojoTest" );
destinationFolder.mkdirs();
mojo = new WebMinifierMojo();
mojo.setDestinationFolder( destinationFolder );
mojo.setSourceFolder( sourceFolder );
mojo.setEncoding( "UTF-8" );
mojo.setYuiDisableOptimizations( false );
mojo.setYuiLinebreak( -1 );
mojo.setYuiMunge( true );
mojo.setYuiPreserveSemi( false );
}
/**
* Tidy up.
*/
@After
public void tearDownMojo()
{
FileUtils.deleteQuietly( mojo.getDestinationFolder() );
}
/**
* Take the MOJO for a normal run.
*
* @throws MojoFailureException if something goes wrong.
* @throws MojoExecutionException if something goes wrong.
*/
@Test
public void testNormalRun()
throws MojoExecutionException, MojoFailureException
{
mojo.execute();
}
}
@huntc
Copy link

huntc commented Oct 21, 2011

Testing the addition of a comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment