View Javadoc
1   /*
2    * SPDX-License-Identifier: LGPL-2.1-or-later
3    * See LICENSE file for details.
4    *
5    * Copyright 2012-2026 Hazendaz.
6    */
7   package net.alchim31.maven.yuicompressor;
8   
9   import com.yahoo.platform.yui.compressor.CssCompressor;
10  import com.yahoo.platform.yui.compressor.JavaScriptCompressor;
11  
12  import java.io.File;
13  import java.io.IOException;
14  import java.io.InputStream;
15  import java.io.InputStreamReader;
16  import java.io.OutputStreamWriter;
17  import java.nio.charset.Charset;
18  import java.nio.file.Files;
19  import java.nio.file.Path;
20  import java.util.Collection;
21  import java.util.HashSet;
22  import java.util.Locale;
23  import java.util.Set;
24  import java.util.zip.GZIPOutputStream;
25  
26  import org.apache.maven.plugin.MojoExecutionException;
27  import org.apache.maven.plugins.annotations.LifecyclePhase;
28  import org.apache.maven.plugins.annotations.Mojo;
29  import org.apache.maven.plugins.annotations.Parameter;
30  import org.codehaus.plexus.util.FileUtils;
31  import org.codehaus.plexus.util.IOUtil;
32  
33  /**
34   * Apply compression on JS and CSS (using YUI Compressor).
35   */
36  @Mojo(name = "compress", defaultPhase = LifecyclePhase.PROCESS_RESOURCES, requiresProject = true, threadSafe = true)
37  public class YuiCompressorMojo extends MojoSupport {
38  
39      /**
40       * Read the input file using "encoding".
41       */
42      @Parameter(defaultValue = "${project.build.sourceEncoding}", property = "file.encoding")
43      private String encoding;
44  
45      /**
46       * The output filename suffix.
47       */
48      @Parameter(defaultValue = "-min", property = "maven.yuicompressor.suffix")
49      private String suffix;
50  
51      /**
52       * If no "suffix" must be add to output filename (maven's configuration manage empty suffix like default).
53       */
54      @Parameter(defaultValue = "false", property = "maven.yuicompressor.nosuffix")
55      private boolean nosuffix;
56  
57      /**
58       * Insert line breaks in output after the specified column number.
59       */
60      @Parameter(defaultValue = "-1", property = "maven.yuicompressor.linebreakpos")
61      private int linebreakpos;
62  
63      /** [js only] No compression. */
64      @Parameter(defaultValue = "false", property = "maven.yuicompressor.nocompress")
65      private boolean nocompress;
66  
67      /**
68       * [js only] Minify only, do not obfuscate.
69       */
70      @Parameter(defaultValue = "false", property = "maven.yuicompressor.nomunge")
71      private boolean nomunge;
72  
73      /**
74       * [js only] Preserve unnecessary semicolons.
75       */
76      @Parameter(defaultValue = "false", property = "maven.yuicompressor.preserveAllSemiColons")
77      private boolean preserveAllSemiColons;
78  
79      /**
80       * [js only] disable all micro optimizations.
81       */
82      @Parameter(defaultValue = "false", property = "maven.yuicompressor.disableOptimizations")
83      private boolean disableOptimizations;
84  
85      /**
86       * force the compression of every files, else if compressed file already exists and is younger than source file,
87       * nothing is done.
88       */
89      @Parameter(defaultValue = "false", property = "maven.yuicompressor.force")
90      private boolean force;
91  
92      /**
93       * a list of aggregation/concatenation to do after processing, for example to create big js files that contain
94       * several small js files. Aggregation could be done on any type of file (js, css, ..).
95       */
96      @Parameter
97      private Aggregation[] aggregations;
98  
99      /**
100      * request to create a gzipped version of the yuicompressed/aggregation files.
101      */
102     @Parameter(defaultValue = "false", property = "maven.yuicompressor.gzip")
103     private boolean gzip;
104 
105     /** gzip level. */
106     @Parameter(defaultValue = "9", property = "maven.yuicompressor.level")
107     private int level;
108 
109     /**
110      * show statistics (compression ratio).
111      */
112     @Parameter(defaultValue = "true", property = "maven.yuicompressor.statistics")
113     private boolean statistics;
114 
115     /** aggregate files before minify. */
116     @Parameter(defaultValue = "false", property = "maven.yuicompressor.preProcessAggregates")
117     private boolean preProcessAggregates;
118 
119     /** use the input file as output when the compressed file is larger than the original. */
120     @Parameter(defaultValue = "true", property = "maven.yuicompressor.useSmallestFile")
121     private boolean useSmallestFile;
122 
123     /** The in size total. */
124     private long inSizeTotal;
125 
126     /** The out size total. */
127     private long outSizeTotal;
128 
129     /** Keep track of updated files for aggregation on incremental builds. */
130     private Set<String> incrementalFiles;
131 
132     @Override
133     protected String[] getDefaultIncludes() {
134         return new String[] { "**/*.css", "**/*.js" };
135     }
136 
137     @Override
138     public void beforeProcess() throws IOException {
139         if (nosuffix) {
140             suffix = "";
141         }
142 
143         if (preProcessAggregates) {
144             aggregate();
145         }
146     }
147 
148     @Override
149     protected void afterProcess() throws IOException {
150         if (statistics && inSizeTotal > 0) {
151             getLog().info(String.format("total input (%db) -> output (%db)[%d%%]", inSizeTotal, outSizeTotal,
152                     outSizeTotal * 100 / inSizeTotal));
153         }
154 
155         if (!preProcessAggregates) {
156             aggregate();
157         }
158     }
159 
160     /**
161      * Aggregate.
162      *
163      * @throws IOException
164      *             the IO exception
165      */
166     private void aggregate() throws IOException {
167         if (aggregations == null) {
168             return;
169         }
170 
171         Set<File> previouslyIncludedFiles = new HashSet<>();
172         for (Aggregation aggregation : aggregations) {
173             getLog().info("generate aggregation : " + aggregation.getOutput());
174             Collection<File> aggregatedFiles = aggregation.run(previouslyIncludedFiles, buildContext, incrementalFiles);
175             previouslyIncludedFiles.addAll(aggregatedFiles);
176 
177             File gzipped = gzipIfRequested(aggregation.getOutput());
178             if (statistics) {
179                 if (gzipped != null) {
180                     getLog().info(String.format("%s (%db) -> %s (%db)[%d%%]", aggregation.getOutput().getName(),
181                             aggregation.getOutput().length(), gzipped.getName(), gzipped.length(),
182                             ratioOfSize(aggregation.getOutput(), gzipped)));
183                 } else if (aggregation.getOutput().exists()) {
184                     getLog().info(String.format("%s (%db)", aggregation.getOutput().getName(),
185                             aggregation.getOutput().length()));
186                 } else {
187                     getLog().warn(String.format("%s not created", aggregation.getOutput().getName()));
188                 }
189             }
190         }
191     }
192 
193     @Override
194     protected void processFile(SourceFile src) throws IOException, MojoExecutionException {
195         File inFile = src.toFile();
196         getLog().debug("on incremental build only compress if input file has Delta");
197         if (buildContext.isIncremental()) {
198             if (!buildContext.hasDelta(inFile)) {
199                 if (getLog().isInfoEnabled()) {
200                     getLog().info("nothing to do, " + inFile + " has no Delta");
201                 }
202                 return;
203             }
204             if (incrementalFiles == null) {
205                 incrementalFiles = new HashSet<>();
206             }
207         }
208 
209         if (getLog().isDebugEnabled()) {
210             getLog().debug("compress file :" + src.toFile() + " to " + src.toDestFile(suffix));
211         }
212 
213         File outFile = src.toDestFile(suffix);
214         if (!nosuffix && isMinifiedFile(inFile)) {
215             return;
216         }
217         getLog().debug("only compress if input file is younger than existing output file");
218         if (!force && outFile.exists() && outFile.lastModified() > inFile.lastModified()) {
219             if (getLog().isInfoEnabled()) {
220                 getLog().info("nothing to do, " + outFile
221                         + " is younger than original, use 'force' option or clean your target");
222             }
223             return;
224         }
225         File outFileTmp = Path.of(outFile.getCanonicalPath() + ".tmp").toFile();
226         FileUtils.forceDelete(outFileTmp);
227 
228         if (!outFile.getParentFile().exists() && !outFile.getParentFile().mkdirs()) {
229             throw new MojoExecutionException("Cannot create resource output directory: " + outFile.getParentFile());
230         }
231         getLog().debug("use a temporary outputfile (in case in == out)");
232 
233         try (InputStreamReader in = new InputStreamReader(Files.newInputStream(inFile.toPath()),
234                 Charset.forName(encoding));
235                 /* outFileTmp will be deleted create with FileOutputStream */
236                 OutputStreamWriter out = new OutputStreamWriter(Files.newOutputStream(outFileTmp.toPath()),
237                         Charset.forName(encoding));) {
238 
239             getLog().debug("start compression");
240             try {
241                 if (nocompress) {
242                     getLog().info("No compression is enabled");
243                     IOUtil.copy(in, out);
244                 } else if (".js".equalsIgnoreCase(src.getExtension())) {
245                     JavaScriptCompressor compressor = new JavaScriptCompressor(in, jsErrorReporter);
246                     compressor.compress(out, linebreakpos, !nomunge, jswarn, preserveAllSemiColons,
247                             disableOptimizations);
248                 } else if (".css".equalsIgnoreCase(src.getExtension())) {
249                     compressCss(in, out);
250                 }
251             } catch (IndexOutOfBoundsException e) {
252                 // This catch exists to not fail the build on YUICompressor bugs.
253                 // 2.4.8 seems to have issue on windows : https://github.com/yui/yuicompressor/issues/78
254                 // 2.4.8 failed to process empty file (demo01) : https://github.com/yui/yuicompressor/issues/130
255                 getLog().warn("YUICompressor failed on file: " + inFile.getName()
256                         + " due to IndexOutOfBoundsException. Skipping this file.");
257                 return;
258             }
259             getLog().debug("end compression");
260         }
261 
262         boolean outputIgnored = useSmallestFile && inFile.length() < outFile.length();
263         if (outputIgnored) {
264             FileUtils.forceDelete(outFileTmp);
265             FileUtils.copyFile(inFile, outFile);
266             getLog().debug("output greater than input, using original instead");
267         } else {
268             FileUtils.forceDelete(outFile);
269             FileUtils.rename(outFileTmp, outFile);
270             buildContext.refresh(outFile);
271         }
272 
273         if (buildContext.isIncremental()) {
274             incrementalFiles.add(outFile.getCanonicalPath());
275         }
276 
277         File gzipped = gzipIfRequested(outFile);
278         if (statistics) {
279             inSizeTotal += inFile.length();
280             outSizeTotal += outFile.length();
281 
282             String fileStatistics;
283             if (outputIgnored) {
284                 fileStatistics = String.format(
285                         "%s (%db) -> %s (%db)[compressed output discarded (exceeded input size)]", inFile.getName(),
286                         inFile.length(), outFile.getName(), outFile.length());
287             } else {
288                 fileStatistics = String.format("%s (%db) -> %s (%db)[%d%%]", inFile.getName(), inFile.length(),
289                         outFile.getName(), outFile.length(), ratioOfSize(inFile, outFile));
290             }
291 
292             if (gzipped != null) {
293                 fileStatistics = fileStatistics + String.format(" -> %s (%db)[%d%%]", gzipped.getName(),
294                         gzipped.length(), ratioOfSize(inFile, gzipped));
295             }
296             getLog().info(fileStatistics);
297         }
298     }
299 
300     /**
301      * Compress css.
302      *
303      * @param in
304      *            the in
305      * @param out
306      *            the out
307      */
308     private void compressCss(InputStreamReader in, OutputStreamWriter out) throws IOException {
309         try {
310             CssCompressor compressor = new CssCompressor(in);
311             compressor.compress(out, linebreakpos);
312         } catch (IllegalArgumentException e) {
313             throw new IllegalArgumentException(
314                     "Unexpected characters found in CSS file. Ensure that the CSS file does not contain '$', and try again",
315                     e);
316         }
317     }
318 
319     /**
320      * Gzip if requested.
321      *
322      * @param file
323      *            the file
324      *
325      * @return the file
326      *
327      * @throws IOException
328      *             the IO exception
329      */
330     protected File gzipIfRequested(File file) throws IOException {
331         if (!gzip || file == null || !file.exists() || "gz".equalsIgnoreCase(FileUtils.getExtension(file.getName()))) {
332             return null;
333         }
334         File gzipped = Path.of(file.getCanonicalFile() + ".gz").toFile();
335         getLog().debug(String.format("create gzip version : %s", gzipped.getName()));
336         try (InputStream in = Files.newInputStream(file.toPath());
337                 GZIPOutputStream out = new GZIPOutputStream(buildContext.newFileOutputStream(gzipped)) {
338                     {
339                         def.setLevel(level);
340                     }
341                 };) {
342             IOUtil.copy(in, out);
343         }
344         return gzipped;
345     }
346 
347     /**
348      * Ratio of size.
349      *
350      * @param file100
351      *            the file 100
352      * @param fileX
353      *            the file X
354      *
355      * @return the long
356      */
357     protected long ratioOfSize(File file100, File fileX) {
358         long v100 = Math.max(file100.length(), 1);
359         long vX = Math.max(fileX.length(), 1);
360         return vX * 100 / v100;
361     }
362 
363     /**
364      * Checks if is minified file.
365      *
366      * @param inFile
367      *            the in file
368      *
369      * @return true, if is minified file
370      */
371     private boolean isMinifiedFile(File inFile) {
372         String filename = inFile.getName().toLowerCase(Locale.getDefault());
373         return filename.endsWith(suffix + ".js") || filename.endsWith(suffix + ".css");
374     }
375 
376 }