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             Set<File> previouslyIncludedFiles = new HashSet<>();
182             for (Aggregation aggregation : aggregations) {
183                 getLog().info("generate aggregation : " + aggregation.getOutput());
184                 Collection<File> aggregatedFiles = aggregation.run(previouslyIncludedFiles, buildContext,
185                         incrementalFiles);
186                 previouslyIncludedFiles.addAll(aggregatedFiles);
187 
188                 File gzipped = gzipIfRequested(aggregation.getOutput());
189                 if (statistics) {
190                     if (gzipped != null) {
191                         getLog().info(String.format("%s (%db) -> %s (%db)[%d%%]", aggregation.getOutput().getName(),
192                                 aggregation.getOutput().length(), gzipped.getName(), gzipped.length(),
193                                 ratioOfSize(aggregation.getOutput(), gzipped)));
194                     } else if (aggregation.getOutput().exists()) {
195                         getLog().info(String.format("%s (%db)", aggregation.getOutput().getName(),
196                                 aggregation.getOutput().length()));
197                     } else {
198                         getLog().warn(String.format("%s not created", aggregation.getOutput().getName()));
199                     }
200                 }
201             }
202         }
203     }
204 
205     @Override
206     protected void processFile(SourceFile src) throws IOException, MojoExecutionException {
207         File inFile = src.toFile();
208         getLog().debug("on incremental build only compress if input file has Delta");
209         if (buildContext.isIncremental()) {
210             if (!buildContext.hasDelta(inFile)) {
211                 if (getLog().isInfoEnabled()) {
212                     getLog().info("nothing to do, " + inFile + " has no Delta");
213                 }
214                 return;
215             }
216             if (incrementalFiles == null) {
217                 incrementalFiles = new HashSet<>();
218             }
219         }
220 
221         if (getLog().isDebugEnabled()) {
222             getLog().debug("compress file :" + src.toFile() + " to " + src.toDestFile(suffix));
223         }
224 
225         File outFile = src.toDestFile(suffix);
226         if (!nosuffix && isMinifiedFile(inFile)) {
227             return;
228         }
229         getLog().debug("only compress if input file is younger than existing output file");
230         if (!force && outFile.exists() && outFile.lastModified() > inFile.lastModified()) {
231             if (getLog().isInfoEnabled()) {
232                 getLog().info("nothing to do, " + outFile
233                         + " is younger than original, use 'force' option or clean your target");
234             }
235             return;
236         }
237         File outFileTmp = Path.of(outFile.getCanonicalPath() + ".tmp").toFile();
238         FileUtils.forceDelete(outFileTmp);
239 
240         if (!outFile.getParentFile().exists() && !outFile.getParentFile().mkdirs()) {
241             throw new MojoExecutionException("Cannot create resource output directory: " + outFile.getParentFile());
242         }
243         getLog().debug("use a temporary outputfile (in case in == out)");
244 
245         try (InputStreamReader in = new InputStreamReader(Files.newInputStream(inFile.toPath()),
246                 Charset.forName(encoding));
247                 /* outFileTmp will be deleted create with FileOutputStream */
248                 OutputStreamWriter out = new OutputStreamWriter(Files.newOutputStream(outFileTmp.toPath()),
249                         Charset.forName(encoding));) {
250 
251             getLog().debug("start compression");
252             if (nocompress) {
253                 getLog().info("No compression is enabled");
254                 IOUtil.copy(in, out);
255             } else if (".js".equalsIgnoreCase(src.getExtension())) {
256                 JavaScriptCompressor compressor = new JavaScriptCompressor(in, jsErrorReporter);
257                 compressor.compress(out, linebreakpos, !nomunge, jswarn, preserveAllSemiColons, disableOptimizations);
258             } else if (".css".equalsIgnoreCase(src.getExtension())) {
259                 compressCss(in, out);
260             }
261             getLog().debug("end compression");
262         }
263 
264         boolean outputIgnored = useSmallestFile && inFile.length() < outFile.length();
265         if (outputIgnored) {
266             FileUtils.forceDelete(outFileTmp);
267             FileUtils.copyFile(inFile, outFile);
268             getLog().debug("output greater than input, using original instead");
269         } else {
270             FileUtils.forceDelete(outFile);
271             FileUtils.rename(outFileTmp, outFile);
272             buildContext.refresh(outFile);
273         }
274 
275         if (buildContext.isIncremental()) {
276             incrementalFiles.add(outFile.getCanonicalPath());
277         }
278 
279         File gzipped = gzipIfRequested(outFile);
280         if (statistics) {
281             inSizeTotal += inFile.length();
282             outSizeTotal += outFile.length();
283 
284             String fileStatistics;
285             if (outputIgnored) {
286                 fileStatistics = String.format(
287                         "%s (%db) -> %s (%db)[compressed output discarded (exceeded input size)]", inFile.getName(),
288                         inFile.length(), outFile.getName(), outFile.length());
289             } else {
290                 fileStatistics = String.format("%s (%db) -> %s (%db)[%d%%]", inFile.getName(), inFile.length(),
291                         outFile.getName(), outFile.length(), ratioOfSize(inFile, outFile));
292             }
293 
294             if (gzipped != null) {
295                 fileStatistics = fileStatistics + String.format(" -> %s (%db)[%d%%]", gzipped.getName(),
296                         gzipped.length(), ratioOfSize(inFile, gzipped));
297             }
298             getLog().info(fileStatistics);
299         }
300     }
301 
302     /**
303      * Compress css.
304      *
305      * @param in
306      *            the in
307      * @param out
308      *            the out
309      */
310     private void compressCss(InputStreamReader in, OutputStreamWriter out) throws IOException {
311         try {
312             CssCompressor compressor = new CssCompressor(in);
313             compressor.compress(out, linebreakpos);
314         } catch (IllegalArgumentException e) {
315             throw new IllegalArgumentException(
316                     "Unexpected characters found in CSS file. Ensure that the CSS file does not contain '$', and try again",
317                     e);
318         }
319     }
320 
321     /**
322      * Gzip if requested.
323      *
324      * @param file
325      *            the file
326      *
327      * @return the file
328      *
329      * @throws IOException
330      *             the IO exception
331      */
332     protected File gzipIfRequested(File file) throws IOException {
333         if (!gzip || file == null || !file.exists() || ".gz".equalsIgnoreCase(FileUtils.getExtension(file.getName()))) {
334             return null;
335         }
336         File gzipped = Path.of(file.getCanonicalFile() + ".gz").toFile();
337         getLog().debug(String.format("create gzip version : %s", gzipped.getName()));
338         try (InputStream in = Files.newInputStream(file.toPath());
339                 GZIPOutputStream out = new GZIPOutputStream(buildContext.newFileOutputStream(gzipped)) {
340                     {
341                         def.setLevel(level);
342                     }
343                 };) {
344             IOUtil.copy(in, out);
345         }
346         return gzipped;
347     }
348 
349     /**
350      * Ratio of size.
351      *
352      * @param file100
353      *            the file 100
354      * @param fileX
355      *            the file X
356      *
357      * @return the long
358      */
359     protected long ratioOfSize(File file100, File fileX) {
360         long v100 = Math.max(file100.length(), 1);
361         long vX = Math.max(fileX.length(), 1);
362         return vX * 100 / v100;
363     }
364 
365     /**
366      * Checks if is minified file.
367      *
368      * @param inFile
369      *            the in file
370      *
371      * @return true, if is minified file
372      */
373     private boolean isMinifiedFile(File inFile) {
374         String filename = inFile.getName().toLowerCase(Locale.getDefault());
375         return filename.endsWith(suffix + ".js") || filename.endsWith(suffix + ".css");
376     }
377 
378 }