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