org.codehaus.mojo.webminifier.WebMinifierMojo.java Source code

Java tutorial

Introduction

Here is the source code for org.codehaus.mojo.webminifier.WebMinifierMojo.java

Source

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.OutputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Properties;
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.mojo.webminifier.closure.ClosureJsCompressor;
import org.codehaus.mojo.webminifier.yui.YuiJsCompressor;
import org.codehaus.plexus.util.DirectoryScanner;
import org.xml.sax.SAXException;

import com.google.javascript.jscomp.CompilationLevel;

/**
 * Mojo to invoke WebMinifier plugin to minify web files.
 * 
 * @author Ben Jones
 * @author Christopher Hunt
 * @goal minify-js
 * @phase prepare-package
 */
public class WebMinifierMojo extends AbstractMojo {
    /**
     * The type of JS Compressor to use.
     */
    public enum JsCompressorType {
        /** Types */
        YUI, CLOSURE, NONE
    }

    /**
     * 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 contains one of these target/classes/js relative 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. Each name in the property corresponds to the relative file path of a file
     * accessible from the destinationFolder e.g. js/a.js would match up with a file located at target/classes/js/a.js.
     * Each property value, if provided, corresponds to the name component of a file that will be generated and without
     * the file extension. If omitted then a numbering scheme will be employed to name the file at the split point.
     * 
     * @parameter
     */
    private Properties jsSplitPoints;

    /**
     * All HTML, JavaScript and CSS files are assumed to have this encoding. 
     * 
     * @parameter expression="${encoding}" default-value="${project.build.sourceEncoding}"
     */
    private String encoding;

    /**
     * The type of compressor to use for JS files.
     * 
     * @parameter default-value="CLOSURE"
     */
    private JsCompressorType jsCompressorType;

    /**
     * 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;

    /**
     * Closure compiler level option either:
     * <ol>
     * <li>WHITESPACE_ONLY</li>
     * <li>SIMPLE_OPTIMIZATIONS</li>
     * <li>ADVANCED_OPTIMIZATIONS</li>
     * </ol>
     * 
     * @parameter default-value="SIMPLE_OPTIMIZATIONS"
     */
    private CompilationLevel closureCompilationLevel;

    /**
     * Whether or not the const keyword is to be accepted.
     * 
     * @parameter default-value="false"
     */
    private boolean closureAcceptConstKeyword;

    /**
     * The project source folder.
     * 
     * @parameter default-value="${basedir}/src/main"
     * @required
     */
    private File projectSourceFolder;

    /**
     * Signals whether or not dependencies should be minified into their own file. For applications, you may want to put
     * everything in a single minified file, but for libraries you do not.
     * 
     * @parameter default-value=true
     * @required
     */
    private boolean splitDependencies;

    /**
     * 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 {
                if (getLog().isDebugEnabled()) {
                    getLog().debug("Concatenating file: " + inputFile);
                }
                IOUtils.copy(is, os);
                os.write(';');
                os.write('\n');
            } 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).

        FileUtils.deleteQuietly(destinationFolder);
        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;

        // If a split point already exists on disk then we've been through the minification process. As
        // minification can be expensive, we would like to avoid performing it multiple times. Thus storing
        // a set of what we've previously minified enables us.
        Set<File> existingConcatenatedJsResources = new HashSet<File>();
        Set<File> consumedJsResources = new HashSet<File>();

        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 = new Properties();
            }

            File concatenatedJsResource = null;

            URI destinationFolderUri = destinationFolder.toURI();

            // Split the js resources into two lists: one containing all external dependencies, the other containing
            // project sources. We do this so that project sources can be minified without the dependencies (libraries
            // generally don't need to distribute the dependencies).
            int jsDependencyProjectResourcesIndex;

            if (splitDependencies) {
                List<File> jsDependencyResources = new ArrayList<File>(jsResources.size());
                List<File> jsProjectResources = new ArrayList<File>(jsResources.size());
                for (File jsResource : jsResources) {
                    String jsResourceUri = destinationFolderUri.relativize(jsResource.toURI()).toString();
                    File jsResourceFile = new File(projectSourceFolder, jsResourceUri);
                    if (jsResourceFile.exists()) {
                        jsProjectResources.add(jsResource);
                    } else {
                        jsDependencyResources.add(jsResource);
                    }
                }

                // Re-constitute the js resource list from dependency resources + project resources and note the index
                // in the list that represents the start of project sources in the list. We need this information later.
                jsDependencyProjectResourcesIndex = jsDependencyResources.size();

                jsResources = jsDependencyResources;
                jsResources.addAll(jsProjectResources);
            } else {
                jsDependencyProjectResourcesIndex = 0;
            }

            // Walk backwards through the script declarations and note what files will map to what split point.
            Map<File, File> jsResourceTargetFiles = new LinkedHashMap<File, File>(jsResources.size());
            ListIterator<File> jsResourcesIter = jsResources.listIterator(jsResources.size());

            boolean splittingDependencies = false;

            while (jsResourcesIter.hasPrevious()) {
                int jsResourceIterIndex = jsResourcesIter.previousIndex();
                File jsResource = jsResourcesIter.previous();

                String candidateSplitPointNameUri = destinationFolderUri.relativize(jsResource.toURI()).toString();
                String splitPointName = (String) jsSplitPoints.get(candidateSplitPointNameUri);

                // If we do not have a split point name and the resource is a dependency of this project i.e. it is not
                // within our src/main folder then we give it a split name of "dependencies". Factoring out dependencies
                // into their own split point is a useful thing to do and will always be required when building
                // libraries.
                if (splitDependencies && splitPointName == null && !splittingDependencies) {
                    if (jsResourceIterIndex < jsDependencyProjectResourcesIndex) {
                        splitPointName = Integer.valueOf(++minifiedCounter).toString();
                        splittingDependencies = true;
                    }
                }

                // If we have no name and we've not been in here before, then assign an initial name based on a number.
                if (splitPointName == null && concatenatedJsResource == null) {
                    splitPointName = Integer.valueOf(++minifiedCounter).toString();
                }

                // We have a new split name so use it for this file and upwards in the script statements until we
                // either hit another split point or there are no more script statements.
                if (splitPointName != null) {
                    concatenatedJsResource = new File(destinationFolder, splitPointName + ".js");

                    // Note that we've previously created this.
                    if (concatenatedJsResource.exists()) {
                        existingConcatenatedJsResources.add(concatenatedJsResource);
                    }
                }

                jsResourceTargetFiles.put(jsResource, concatenatedJsResource);
            }

            for (File jsResource : jsResources) {
                concatenatedJsResource = jsResourceTargetFiles.get(jsResource);
                if (!existingConcatenatedJsResources.contains(concatenatedJsResource)) {
                    // Concatenate input file onto output resource file
                    try {
                        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).
                    consumedJsResources.add(jsResource);
                }
            }

            // Reduce the list of js resource target files to a distinct set
            LinkedHashSet<File> concatenatedJsResourcesSet = new LinkedHashSet<File>(
                    jsResourceTargetFiles.values());
            File[] concatenatedJsResourcesArray = new File[concatenatedJsResourcesSet.size()];
            concatenatedJsResourcesSet.toArray(concatenatedJsResourcesArray);
            List<File> concatenatedJsResources = Arrays.asList(concatenatedJsResourcesArray);

            // Minify the concatenated JS resource files

            if (jsCompressorType != JsCompressorType.NONE) {
                List<File> minifiedJSResources = new ArrayList<File>(concatenatedJsResources.size());

                ListIterator<File> concatenatedJsResourcesIter = concatenatedJsResources
                        .listIterator(concatenatedJsResources.size());
                while (concatenatedJsResourcesIter.hasPrevious()) {
                    concatenatedJsResource = concatenatedJsResourcesIter.previous();

                    File minifiedJSResource;
                    try {
                        String uri = concatenatedJsResource.toURI().toString();
                        int i = uri.lastIndexOf(".js");
                        String minUri;
                        if (i > -1) {
                            minUri = uri.substring(0, i) + "-min.js";
                        } else {
                            minUri = uri;
                        }
                        minifiedJSResource = FileUtils.toFile(new URL(minUri));
                    } catch (MalformedURLException e) {
                        throw new MojoExecutionException("Problem determining file URL", e);
                    }

                    minifiedJSResources.add(minifiedJSResource);

                    // If we've not actually performed the minification before... then do so. This is the expensive bit
                    // so we like to avoid it if we can.
                    if (!existingConcatenatedJsResources.contains(concatenatedJsResource)) {
                        boolean warningsFound;
                        try {
                            warningsFound = minifyJSFile(concatenatedJsResource, minifiedJSResource);
                        } catch (IOException e) {
                            throw new MojoExecutionException("Problem reading/writing JS", e);
                        }

                        logCompressionRatio(minifiedJSResource.getName(), concatenatedJsResource.length(),
                                minifiedJSResource.length());

                        // If there were warnings then the user may want to manually invoke the compressor for further
                        // investigation.
                        if (warningsFound) {
                            getLog().warn("Warnings were found. " + concatenatedJsResource
                                    + " is available for your further investigations.");
                        }
                    }
                }

                // Update source references
                replacer.replaceJSResources(destinationFolder, targetHTML, minifiedJSResources);
            } else {
                List<File> unminifiedJSResources = new ArrayList<File>(concatenatedJsResources.size());

                ListIterator<File> concatenatedJsResourcesIter = concatenatedJsResources
                        .listIterator(concatenatedJsResources.size());
                while (concatenatedJsResourcesIter.hasPrevious()) {
                    concatenatedJsResource = concatenatedJsResourcesIter.previous();
                    unminifiedJSResources.add(concatenatedJsResource);
                }

                replacer.replaceJSResources(destinationFolder, targetHTML, unminifiedJSResources);
                getLog().info("Concatenated resources with no compression");
            }

            // 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);
            }

        }

        // Clean up including the destination folder recursively where directories have nothing left in them.
        for (File consumedJsResource : consumedJsResources) {
            consumedJsResource.delete();
        }
        removeEmptyFolders(destinationFolder);
    }

    /**
     * @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 JsCompressorType getJsCompressorType() {
        return jsCompressorType;
    }

    /**
     * @return property
     */
    public Properties 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 getProjectSourceFolder() {
        return projectSourceFolder;
    }

    /**
     * @return property
     */
    public File getSourceFolder() {
        return sourceFolder;
    }

    /**
     * @return property
     */
    public int getYuiLinebreak() {
        return yuiLinebreak;
    }

    /**
     * @return property.
     */
    public boolean isSplitDependencies() {
        return splitDependencies;
    }

    /**
     * @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.
     * @throws MojoExecutionException if there's a problem during compression.
     * @return true if minification succeeded with no warnings.
     */
    private boolean minifyJSFile(File source, File target) throws IOException, MojoExecutionException {
        boolean warningsFound = false;

        // Minify JS file and append to output JS file
        InputStream is = new BufferedInputStream(new FileInputStream(source));
        try {
            OutputStream os = new FileOutputStream(target);
            try {
                AbstractCompressor compressor;
                switch (jsCompressorType) {
                case YUI:
                    compressor = new YuiJsCompressor(is, os, encoding, getLog());
                    ((YuiJsCompressor) compressor).setOptions(yuiLinebreak, yuiMunge, yuiPreserveSemi,
                            yuiDisableOptimizations);
                    break;
                case CLOSURE:
                    compressor = new ClosureJsCompressor(is, os, encoding, getLog());
                    ((ClosureJsCompressor) compressor).setOptions(closureCompilationLevel,
                            closureAcceptConstKeyword);
                    break;
                default:
                    assert false;
                    compressor = null;
                }

                if (compressor != null) {
                    compressor.compress();
                    ExceptionState exceptionState = compressor.getExceptionState();
                    if (exceptionState.hasErrors()) {
                        throw new MojoExecutionException("Problem(s) prevented compression from completing.");
                    } else {
                        warningsFound = exceptionState.hasWarnings();
                    }
                }
            } finally {
                os.close();
            }
        } finally {
            is.close();
        }

        return warningsFound;
    }

    private void removeEmptyFolders(File folder) {
        File[] files = folder.listFiles();
        boolean folderHasFile = false;
        for (File file : files) {
            if (file.isDirectory()) {
                removeEmptyFolders(file);
            } else {
                folderHasFile = true;
            }
        }
        if (!folderHasFile) {
            folder.delete();
        }
    }

    /**
     * @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 jsCompressorType to set.
     */
    public void setJsCompressorType(JsCompressorType jsCompressorType) {
        this.jsCompressorType = jsCompressorType;
    }

    /**
     * @param jsSplitPoints to set.
     */
    public void setJsSplitPoints(Properties jsSplitPoints) {
        this.jsSplitPoints = jsSplitPoints;
    }

    /**
     * @param projectSourceFolder set property.
     */
    public void setProjectSourceFolder(File projectSourceFolder) {
        this.projectSourceFolder = projectSourceFolder;
    }

    /**
     * @param sourceFolder to set.
     */
    public void setSourceFolder(File sourceFolder) {
        this.sourceFolder = sourceFolder;
    }

    /**
     * @param splitDependencies Set the property.
     */
    public void setSplitDependencies(boolean splitDependencies) {
        this.splitDependencies = splitDependencies;
    }

    /**
     * @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;
    }
}