View Javadoc
1   /*
2    *    Copyright 2009-2025 the original author or authors.
3    *
4    *    Licensed under the Apache License, Version 2.0 (the "License");
5    *    you may not use this file except in compliance with the License.
6    *    You may obtain a copy of the License at
7    *
8    *       http://www.apache.org/licenses/LICENSE-2.0
9    *
10   *    Unless required by applicable law or agreed to in writing, software
11   *    distributed under the License is distributed on an "AS IS" BASIS,
12   *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   *    See the License for the specific language governing permissions and
14   *    limitations under the License.
15   */
16  package com.googlecode.htmlcompressor;
17  
18  import com.google.javascript.jscomp.CompilationLevel;
19  import com.google.javascript.jscomp.SourceFile;
20  import com.googlecode.htmlcompressor.analyzer.HtmlAnalyzer;
21  import com.googlecode.htmlcompressor.compressor.ClosureJavaScriptCompressor;
22  import com.googlecode.htmlcompressor.compressor.Compressor;
23  import com.googlecode.htmlcompressor.compressor.HtmlCompressor;
24  import com.googlecode.htmlcompressor.compressor.XmlCompressor;
25  
26  import java.io.BufferedReader;
27  import java.io.Closeable;
28  import java.io.File;
29  import java.io.FileFilter;
30  import java.io.IOException;
31  import java.io.InputStreamReader;
32  import java.io.OutputStreamWriter;
33  import java.io.Writer;
34  import java.net.URI;
35  import java.nio.charset.Charset;
36  import java.nio.charset.StandardCharsets;
37  import java.nio.file.Files;
38  import java.nio.file.Path;
39  import java.util.ArrayDeque;
40  import java.util.ArrayList;
41  import java.util.HashMap;
42  import java.util.List;
43  import java.util.Locale;
44  import java.util.Map;
45  import java.util.regex.Matcher;
46  import java.util.regex.Pattern;
47  import java.util.regex.PatternSyntaxException;
48  
49  import org.slf4j.Logger;
50  import org.slf4j.LoggerFactory;
51  
52  import jargs.gnu.CmdLineParser;
53  import jargs.gnu.CmdLineParser.Option;
54  import jargs.gnu.CmdLineParser.OptionException;
55  
56  /**
57   * Wrapper for HTML and XML compressor classes that allows using them from a command line.
58   * <p>
59   * Usage: <code>java -jar htmlcompressor.jar [options] [input]</code>
60   * <p>
61   * To view a list of all available parameters please run with <code>-?</code> option:
62   * <p>
63   * <code>java -jar htmlcompressor.jar -?</code>
64   *
65   * @author <a href="mailto:serg472@gmail.com">Sergiy Kovalchuk</a>
66   */
67  public class CmdLineCompressor {
68  
69      /** The Constant logger. */
70      private static final Logger logger = LoggerFactory.getLogger(CmdLineCompressor.class);
71  
72      /** The Constant urlPattern. */
73      private static final Pattern urlPattern = Pattern.compile("^https?://.*$", Pattern.CASE_INSENSITIVE);
74  
75      /** The help opt. */
76      private boolean helpOpt;
77  
78      /** The analyze opt. */
79      private boolean analyzeOpt;
80  
81      /** The charset opt. */
82      private Charset charsetOpt;
83  
84      /** The output filename opt. */
85      private String outputFilenameOpt;
86  
87      /** The patterns filename opt. */
88      private String patternsFilenameOpt;
89  
90      /** The type opt. */
91      private String typeOpt;
92  
93      /** The filemask opt. */
94      private String filemaskOpt;
95  
96      /** The recursive opt. */
97      private boolean recursiveOpt;
98  
99      /** The preserve comments opt. */
100     private boolean preserveCommentsOpt;
101 
102     /** The preserve intertag spaces opt. */
103     private boolean preserveIntertagSpacesOpt;
104 
105     /** The preserve multi spaces opt. */
106     private boolean preserveMultiSpacesOpt;
107 
108     /** The remove intertag spaces opt. */
109     private boolean removeIntertagSpacesOpt;
110 
111     /** The remove quotes opt. */
112     private boolean removeQuotesOpt;
113 
114     /** The remove surrounding spaces opt. */
115     private String removeSurroundingSpacesOpt;
116 
117     /** The preserve line breaks opt. */
118     private boolean preserveLineBreaksOpt;
119 
120     /** The preserve php tags opt. */
121     private boolean preservePhpTagsOpt;
122 
123     /** The preserve server script tags opt. */
124     private boolean preserveServerScriptTagsOpt;
125 
126     /** The preserve ssi tags opt. */
127     private boolean preserveSsiTagsOpt;
128 
129     /** The compress js opt. */
130     private boolean compressJsOpt;
131 
132     /** The compress css opt. */
133     private boolean compressCssOpt;
134 
135     /** The js compressor opt. */
136     private String jsCompressorOpt;
137 
138     /** The simple doctype opt. */
139     private boolean simpleDoctypeOpt;
140 
141     /** The remove script attributes opt. */
142     private boolean removeScriptAttributesOpt;
143 
144     /** The remove style attributes opt. */
145     private boolean removeStyleAttributesOpt;
146 
147     /** The remove link attributes opt. */
148     private boolean removeLinkAttributesOpt;
149 
150     /** The remove form attributes opt. */
151     private boolean removeFormAttributesOpt;
152 
153     /** The remove input attributes opt. */
154     private boolean removeInputAttributesOpt;
155 
156     /** The simple boolean attributes opt. */
157     private boolean simpleBooleanAttributesOpt;
158 
159     /** The remove java script protocol opt. */
160     private boolean removeJavaScriptProtocolOpt;
161 
162     /** The remove http protocol opt. */
163     private boolean removeHttpProtocolOpt;
164 
165     /** The remove https protocol opt. */
166     private boolean removeHttpsProtocolOpt;
167 
168     /** The nomunge opt. */
169     private boolean nomungeOpt;
170 
171     /** The linebreak opt. */
172     private int linebreakOpt;
173 
174     /** The preserve semi opt. */
175     private boolean preserveSemiOpt;
176 
177     /** The disable optimizations opt. */
178     private boolean disableOptimizationsOpt;
179 
180     /** The closure opt level opt. */
181     private String closureOptLevelOpt;
182 
183     /** The closure custom externs only opt. */
184     private boolean closureCustomExternsOnlyOpt;
185 
186     /** The closure externs opt. */
187     private List<String> closureExternsOpt;
188 
189     /** The file args opt. */
190     private List<String> fileArgsOpt;
191 
192     /**
193      * Instantiates a new cmd line compressor.
194      *
195      * @param args
196      *            the args
197      */
198     public CmdLineCompressor(String[] args) {
199         CmdLineParser parser = new CmdLineParser();
200 
201         Option helpOption = parser.addBooleanOption('h', "help");
202         Option helpOptAlt = parser.addBooleanOption('?', "help_alt");
203         Option analyzeOption = parser.addBooleanOption('a', "analyze");
204         Option recursiveOption = parser.addBooleanOption('r', "recursive");
205         Option charsetOption = parser.addStringOption('c', "charset");
206         Option outputFilenameOption = parser.addStringOption('o', "output");
207         Option patternsFilenameOption = parser.addStringOption('p', "preserve");
208         Option typeOption = parser.addStringOption('t', "type");
209         Option filemaskOption = parser.addStringOption('m', "mask");
210         Option preserveCommentsOption = parser.addBooleanOption("preserve-comments");
211         Option preserveIntertagSpacesOption = parser.addBooleanOption("preserve-intertag-spaces");
212         Option preserveMultiSpacesOption = parser.addBooleanOption("preserve-multi-spaces");
213         Option removeIntertagSpacesOption = parser.addBooleanOption("remove-intertag-spaces");
214         Option removeSurroundingSpacesOption = parser.addStringOption("remove-surrounding-spaces");
215         Option removeQuotesOption = parser.addBooleanOption("remove-quotes");
216         Option preserveLineBreaksOption = parser.addBooleanOption("preserve-line-breaks");
217         Option preservePhpTagsOption = parser.addBooleanOption("preserve-php");
218         Option preserveServerScriptTagsOption = parser.addBooleanOption("preserve-server-script");
219         Option preserveSsiTagsOption = parser.addBooleanOption("preserve-ssi");
220         Option compressJsOption = parser.addBooleanOption("compress-js");
221         Option compressCssOption = parser.addBooleanOption("compress-css");
222         Option jsCompressorOption = parser.addStringOption("js-compressor");
223 
224         Option simpleDoctypeOption = parser.addBooleanOption("simple-doctype");
225         Option removeScriptAttributesOption = parser.addBooleanOption("remove-script-attr");
226         Option removeStyleAttributesOption = parser.addBooleanOption("remove-style-attr");
227         Option removeLinkAttributesOption = parser.addBooleanOption("remove-link-attr");
228         Option removeFormAttributesOption = parser.addBooleanOption("remove-form-attr");
229         Option removeInputAttributesOption = parser.addBooleanOption("remove-input-attr");
230         Option simpleBooleanAttributesOption = parser.addBooleanOption("simple-bool-attr");
231         Option removeJavaScriptProtocolOption = parser.addBooleanOption("remove-js-protocol");
232         Option removeHttpProtocolOption = parser.addBooleanOption("remove-http-protocol");
233         Option removeHttpsProtocolOption = parser.addBooleanOption("remove-https-protocol");
234 
235         Option nomungeOption = parser.addBooleanOption("nomunge");
236         Option linebreakOption = parser.addStringOption("line-break");
237         Option preserveSemiOption = parser.addBooleanOption("preserve-semi");
238         Option disableOptimizationsOption = parser.addBooleanOption("disable-optimizations");
239 
240         Option closureOptLevelOption = parser.addStringOption("closure-opt-level");
241         Option closureCustomExternsOnlyOption = parser.addBooleanOption("closure-custom-externs-only");
242         Option closureExternsOption = parser.addStringOption("closure-externs");
243 
244         try {
245             parser.parse(args);
246 
247             this.helpOpt = (Boolean) parser.getOptionValue(helpOption, false)
248                     || (Boolean) parser.getOptionValue(helpOptAlt, false);
249             this.analyzeOpt = (Boolean) parser.getOptionValue(analyzeOption, false);
250             this.recursiveOpt = (Boolean) parser.getOptionValue(recursiveOption, false);
251             this.charsetOpt = Charset.forName((String) parser.getOptionValue(charsetOption, "UTF-8"));
252             this.outputFilenameOpt = (String) parser.getOptionValue(outputFilenameOption);
253             this.patternsFilenameOpt = (String) parser.getOptionValue(patternsFilenameOption);
254             this.typeOpt = (String) parser.getOptionValue(typeOption);
255             this.filemaskOpt = (String) parser.getOptionValue(filemaskOption);
256             this.preserveCommentsOpt = (Boolean) parser.getOptionValue(preserveCommentsOption, false);
257             this.preserveIntertagSpacesOpt = (Boolean) parser.getOptionValue(preserveIntertagSpacesOption, false);
258             this.preserveMultiSpacesOpt = (Boolean) parser.getOptionValue(preserveMultiSpacesOption, false);
259             this.removeIntertagSpacesOpt = (Boolean) parser.getOptionValue(removeIntertagSpacesOption, false);
260             this.removeQuotesOpt = (Boolean) parser.getOptionValue(removeQuotesOption, false);
261             this.preserveLineBreaksOpt = (Boolean) parser.getOptionValue(preserveLineBreaksOption, false);
262             this.preservePhpTagsOpt = (Boolean) parser.getOptionValue(preservePhpTagsOption, false);
263             this.preserveServerScriptTagsOpt = (Boolean) parser.getOptionValue(preserveServerScriptTagsOption, false);
264             this.preserveSsiTagsOpt = (Boolean) parser.getOptionValue(preserveSsiTagsOption, false);
265             this.compressJsOpt = (Boolean) parser.getOptionValue(compressJsOption, false);
266             this.compressCssOpt = (Boolean) parser.getOptionValue(compressCssOption, false);
267             this.jsCompressorOpt = (String) parser.getOptionValue(jsCompressorOption, HtmlCompressor.JS_COMPRESSOR_YUI);
268 
269             this.simpleDoctypeOpt = (Boolean) parser.getOptionValue(simpleDoctypeOption, false);
270             this.removeScriptAttributesOpt = (Boolean) parser.getOptionValue(removeScriptAttributesOption, false);
271             this.removeStyleAttributesOpt = (Boolean) parser.getOptionValue(removeStyleAttributesOption, false);
272             this.removeLinkAttributesOpt = (Boolean) parser.getOptionValue(removeLinkAttributesOption, false);
273             this.removeFormAttributesOpt = (Boolean) parser.getOptionValue(removeFormAttributesOption, false);
274             this.removeInputAttributesOpt = (Boolean) parser.getOptionValue(removeInputAttributesOption, false);
275             this.simpleBooleanAttributesOpt = (Boolean) parser.getOptionValue(simpleBooleanAttributesOption, false);
276             this.removeJavaScriptProtocolOpt = (Boolean) parser.getOptionValue(removeJavaScriptProtocolOption, false);
277             this.removeHttpProtocolOpt = (Boolean) parser.getOptionValue(removeHttpProtocolOption, false);
278             this.removeHttpsProtocolOpt = (Boolean) parser.getOptionValue(removeHttpsProtocolOption, false);
279 
280             this.nomungeOpt = (Boolean) parser.getOptionValue(nomungeOption, false);
281             this.linebreakOpt = (Integer) parser.getOptionValue(linebreakOption, -1);
282             this.preserveSemiOpt = (Boolean) parser.getOptionValue(preserveSemiOption, false);
283             this.disableOptimizationsOpt = (Boolean) parser.getOptionValue(disableOptimizationsOption, false);
284 
285             this.closureOptLevelOpt = (String) parser.getOptionValue(closureOptLevelOption,
286                     ClosureJavaScriptCompressor.COMPILATION_LEVEL_SIMPLE);
287             this.closureCustomExternsOnlyOpt = (Boolean) parser.getOptionValue(closureCustomExternsOnlyOption, false);
288 
289             this.closureExternsOpt = parser.getOptionValues(closureExternsOption);
290 
291             this.removeSurroundingSpacesOpt = (String) parser.getOptionValue(removeSurroundingSpacesOption);
292             if (this.removeSurroundingSpacesOpt != null) {
293                 if ("min".equalsIgnoreCase(this.removeSurroundingSpacesOpt)) {
294                     this.removeSurroundingSpacesOpt = HtmlCompressor.BLOCK_TAGS_MIN;
295                 } else if ("max".equalsIgnoreCase(this.removeSurroundingSpacesOpt)) {
296                     this.removeSurroundingSpacesOpt = HtmlCompressor.BLOCK_TAGS_MAX;
297                 } else if ("all".equalsIgnoreCase(this.removeSurroundingSpacesOpt)) {
298                     this.removeSurroundingSpacesOpt = HtmlCompressor.ALL_TAGS;
299                 }
300             }
301 
302             // input file
303             this.fileArgsOpt = parser.getRemainingArgs();
304 
305             // charset
306             this.charsetOpt = Charset.isSupported(this.charsetOpt.name()) ? this.charsetOpt : StandardCharsets.UTF_8;
307 
308             // look for "/?"
309             for (int i = 0; i < args.length; i++) {
310                 if ("/?".equals(args[i])) {
311                     this.helpOpt = true;
312                     break;
313                 }
314             }
315 
316         } catch (OptionException e) {
317             logger.info("{}", e.getMessage());
318             logger.trace("", e);
319             printUsage();
320         }
321 
322     }
323 
324     /**
325      * The main method.
326      *
327      * @param args
328      *            the arguments
329      */
330     public static void main(String[] args) {
331         CmdLineCompressor cmdLineCompressor = new CmdLineCompressor(args);
332         cmdLineCompressor.process();
333     }
334 
335     /**
336      * Process.
337      */
338     public void process() {
339         try {
340 
341             // help
342             if (helpOpt) {
343                 printUsage();
344                 return;
345             }
346 
347             // type
348             String type = typeOpt;
349             if (type != null && !"html".equalsIgnoreCase(type) && !"xml".equalsIgnoreCase(type)) {
350                 throw new IllegalArgumentException("Unknown type: " + type);
351             }
352 
353             if (fileArgsOpt.isEmpty()) {
354                 // html by default for stdin
355                 if (type == null) {
356                     type = "html";
357                 }
358             } else if (type == null) {
359                 // detect type from extension
360                 if (fileArgsOpt.get(0).toLowerCase(Locale.ENGLISH).endsWith(".xml")) {
361                     type = "xml";
362                 } else {
363                     type = "html";
364                 }
365             }
366 
367             if (analyzeOpt) {
368                 // analyzer mode
369                 HtmlAnalyzer analyzer = new HtmlAnalyzer(
370                         HtmlCompressor.JS_COMPRESSOR_CLOSURE.equalsIgnoreCase(jsCompressorOpt)
371                                 ? HtmlCompressor.JS_COMPRESSOR_CLOSURE
372                                 : HtmlCompressor.JS_COMPRESSOR_YUI);
373                 analyzer.analyze(readResource(buildReader(fileArgsOpt.isEmpty() ? null : fileArgsOpt.get(0))));
374             } else {
375                 // compression mode
376                 Compressor compressor = "xml".equalsIgnoreCase(type) ? createXmlCompressor() : createHtmlCompressor();
377                 Map<String, String> ioMap = buildInputOutputMap();
378                 for (Map.Entry<String, String> entry : ioMap.entrySet()) {
379                     writeResource(compressor.compress(readResource(buildReader(entry.getKey()))),
380                             buildWriter(entry.getValue()));
381                 }
382             }
383 
384         } catch (NoClassDefFoundError e) {
385             if (HtmlCompressor.JS_COMPRESSOR_CLOSURE.equalsIgnoreCase(jsCompressorOpt)) {
386                 logger.info("""
387                         ERROR: For JavaScript compression using Google Closure Compiler
388                         additional jar file called compiler.jar must be present
389                         in the same directory as HtmlCompressor jar
390                         """);
391             } else {
392                 logger.info("""
393                         ERROR: For CSS or JavaScript compression using YUICompressor additional jar file
394                         called yuicompressor.jar must be present\n" + "in the same directory as HtmlCompressor jar
395                         """);
396             }
397             logger.trace("", e);
398         } catch (OptionException e) {
399             logger.info("{}", e.getMessage());
400             logger.trace("", e);
401             printUsage();
402         } catch (IOException | IllegalArgumentException e) {
403             logger.info("{}", e.getMessage());
404             logger.trace("", e);
405         }
406 
407     }
408 
409     /**
410      * Creates the html compressor.
411      *
412      * @return the compressor
413      *
414      * @throws OptionException
415      *             the option exception
416      */
417     private Compressor createHtmlCompressor() throws OptionException {
418 
419         boolean useClosureCompressor = HtmlCompressor.JS_COMPRESSOR_CLOSURE.equalsIgnoreCase(jsCompressorOpt);
420 
421         // custom preserve patterns
422         List<Pattern> preservePatterns = new ArrayList<>();
423 
424         // predefined
425         if (preservePhpTagsOpt) {
426             preservePatterns.add(HtmlCompressor.PHP_TAG_PATTERN);
427         }
428 
429         if (preserveServerScriptTagsOpt) {
430             preservePatterns.add(HtmlCompressor.SERVER_SCRIPT_TAG_PATTERN);
431         }
432 
433         if (preserveSsiTagsOpt) {
434             preservePatterns.add(HtmlCompressor.SERVER_SIDE_INCLUDE_PATTERN);
435         }
436 
437         if (patternsFilenameOpt != null) {
438 
439             try (BufferedReader patternsIn = Files.newBufferedReader(Path.of(patternsFilenameOpt), charsetOpt)) {
440 
441                 String line = null;
442                 while ((line = patternsIn.readLine()) != null) {
443                     if (line.length() > 0) {
444                         try {
445                             preservePatterns.add(Pattern.compile(line));
446                         } catch (PatternSyntaxException e) {
447                             logger.trace("", e);
448                             throw new IllegalArgumentException(
449                                     "Regular expression compilation error: " + e.getMessage());
450                         }
451                     }
452                 }
453             } catch (IOException e) {
454                 logger.trace("", e);
455                 throw new IllegalArgumentException("Unable to read custom pattern definitions file: " + e.getMessage());
456             }
457         }
458 
459         // set compressor options
460         HtmlCompressor htmlCompressor = new HtmlCompressor();
461 
462         htmlCompressor.setRemoveComments(!preserveCommentsOpt);
463         htmlCompressor.setRemoveMultiSpaces(!preserveMultiSpacesOpt);
464         htmlCompressor.setRemoveIntertagSpaces(removeIntertagSpacesOpt);
465         htmlCompressor.setRemoveQuotes(removeQuotesOpt);
466         htmlCompressor.setPreserveLineBreaks(preserveLineBreaksOpt);
467         htmlCompressor.setCompressJavaScript(compressJsOpt);
468         htmlCompressor.setCompressCss(compressCssOpt);
469 
470         htmlCompressor.setSimpleDoctype(simpleDoctypeOpt);
471         htmlCompressor.setRemoveScriptAttributes(removeScriptAttributesOpt);
472         htmlCompressor.setRemoveStyleAttributes(removeStyleAttributesOpt);
473         htmlCompressor.setRemoveLinkAttributes(removeLinkAttributesOpt);
474         htmlCompressor.setRemoveFormAttributes(removeFormAttributesOpt);
475         htmlCompressor.setRemoveInputAttributes(removeInputAttributesOpt);
476         htmlCompressor.setSimpleBooleanAttributes(simpleBooleanAttributesOpt);
477         htmlCompressor.setRemoveJavaScriptProtocol(removeJavaScriptProtocolOpt);
478         htmlCompressor.setRemoveHttpProtocol(removeHttpProtocolOpt);
479         htmlCompressor.setRemoveHttpsProtocol(removeHttpsProtocolOpt);
480         htmlCompressor.setRemoveSurroundingSpaces(removeSurroundingSpacesOpt);
481 
482         htmlCompressor.setPreservePatterns(preservePatterns);
483 
484         htmlCompressor.setYuiJsNoMunge(nomungeOpt);
485         htmlCompressor.setYuiJsPreserveAllSemiColons(preserveSemiOpt);
486         htmlCompressor.setYuiJsDisableOptimizations(disableOptimizationsOpt);
487         htmlCompressor.setYuiJsLineBreak(linebreakOpt);
488         htmlCompressor.setYuiCssLineBreak(linebreakOpt);
489 
490         // switch js compressor to closure
491         if (compressJsOpt && useClosureCompressor) {
492             ClosureJavaScriptCompressor closureCompressor = new ClosureJavaScriptCompressor();
493 
494             if (closureOptLevelOpt.equalsIgnoreCase(ClosureJavaScriptCompressor.COMPILATION_LEVEL_ADVANCED)) {
495                 closureCompressor.setCompilationLevel(CompilationLevel.ADVANCED_OPTIMIZATIONS);
496                 closureCompressor.setCustomExternsOnly(closureCustomExternsOnlyOpt);
497 
498                 // get externs
499                 if (!closureExternsOpt.isEmpty()) {
500                     List<SourceFile> externs = new ArrayList<>();
501                     for (String externFile : closureExternsOpt) {
502                         externs.add(SourceFile.fromFile(externFile));
503                     }
504                     closureCompressor.setExterns(externs);
505                 }
506             } else if (closureOptLevelOpt.equalsIgnoreCase(ClosureJavaScriptCompressor.COMPILATION_LEVEL_WHITESPACE)) {
507                 closureCompressor.setCompilationLevel(CompilationLevel.WHITESPACE_ONLY);
508             } else {
509                 closureCompressor.setCompilationLevel(CompilationLevel.SIMPLE_OPTIMIZATIONS);
510             }
511 
512             htmlCompressor.setJavaScriptCompressor(closureCompressor);
513         }
514 
515         return htmlCompressor;
516     }
517 
518     /**
519      * Creates the xml compressor.
520      *
521      * @return the compressor
522      *
523      * @throws IllegalArgumentException
524      *             the illegal argument exception
525      * @throws OptionException
526      *             the option exception
527      */
528     private Compressor createXmlCompressor() throws IllegalArgumentException, OptionException {
529         XmlCompressor xmlCompressor = new XmlCompressor();
530         xmlCompressor.setRemoveComments(!preserveCommentsOpt);
531         xmlCompressor.setRemoveIntertagSpaces(!preserveIntertagSpacesOpt);
532 
533         return xmlCompressor;
534     }
535 
536     /**
537      * Builds the input output map.
538      *
539      * @return the map
540      *
541      * @throws IllegalArgumentException
542      *             the illegal argument exception
543      * @throws IOException
544      *             Signals that an I/O exception has occurred.
545      */
546     private Map<String, String> buildInputOutputMap() throws IllegalArgumentException, IOException {
547         Map<String, String> map = new HashMap<>();
548 
549         Path outputFile = null;
550         if (outputFilenameOpt != null) {
551             outputFile = Path.of(outputFilenameOpt);
552 
553             // make dirs
554             if (outputFilenameOpt.endsWith("/") || outputFilenameOpt.endsWith("\\")) {
555                 Files.createDirectories(outputFile);
556             } else {
557                 Files.createDirectories(outputFile.toFile().getCanonicalFile().getParentFile().toPath());
558             }
559         }
560 
561         if (fileArgsOpt.size() > 1 && (outputFile == null || !Files.isDirectory(outputFile))) {
562             throw new IllegalArgumentException("Output must be a directory and end with a slash (/)");
563         }
564 
565         if (fileArgsOpt.isEmpty()) {
566             map.put(null, outputFilenameOpt);
567         } else {
568             for (int i = 0; i < fileArgsOpt.size(); i++) {
569                 if (!urlPattern.matcher(fileArgsOpt.get(i)).matches()) {
570                     Path inputFile = Path.of(fileArgsOpt.get(i));
571                     if (Files.isDirectory(inputFile)) {
572                         // is directory
573                         if (outputFile != null && Files.isDirectory(outputFile)) {
574                             if (!recursiveOpt) {
575                                 // non-recursive
576                                 for (File file : inputFile.toFile()
577                                         .listFiles(new CompressorFileFilter(typeOpt, filemaskOpt, false))) {
578                                     if (!file.isDirectory()) {
579                                         String from = file.getCanonicalPath();
580                                         String to = from.replaceFirst(escRegEx(inputFile.toFile().getCanonicalPath()),
581                                                 Matcher.quoteReplacement(outputFile.toFile().getCanonicalPath()));
582                                         map.put(from, to);
583                                     }
584                                 }
585                             } else {
586                                 // recursive
587                                 ArrayDeque<File> fileStack = new ArrayDeque<>();
588                                 fileStack.push(inputFile.toFile());
589                                 while (!fileStack.isEmpty()) {
590                                     File child = fileStack.pop();
591                                     if (child.isDirectory()) {
592                                         for (File f : child
593                                                 .listFiles(new CompressorFileFilter(typeOpt, filemaskOpt, true))) {
594                                             fileStack.push(f);
595                                         }
596                                     } else if (child.isFile()) {
597                                         String from = child.getCanonicalPath();
598                                         String to = from.replaceFirst(escRegEx(inputFile.toFile().getCanonicalPath()),
599                                                 Matcher.quoteReplacement(outputFile.toFile().getCanonicalPath()));
600                                         map.put(from, to);
601                                         // make directories
602                                         Files.createDirectories(
603                                                 Path.of(to).toFile().getCanonicalFile().getParentFile().toPath());
604                                     }
605                                 }
606                             }
607                         } else {
608                             throw new IllegalArgumentException("Output must be a directory and end with a slash (/)");
609                         }
610                     } else {
611                         // is file
612                         if (outputFile != null && Files.isDirectory(outputFile)) {
613                             String from = inputFile.toFile().getCanonicalPath();
614                             String to = from.replaceFirst(
615                                     escRegEx(inputFile.toFile().getCanonicalFile().getParentFile().getCanonicalPath()),
616                                     Matcher.quoteReplacement(outputFile.toFile().getCanonicalPath()));
617                             map.put(fileArgsOpt.get(i), to);
618                         } else {
619                             map.put(fileArgsOpt.get(i), outputFilenameOpt);
620                         }
621 
622                     }
623                 } else {
624                     // is url
625                     if (fileArgsOpt.size() == 1 && (outputFile == null || !Files.isDirectory(outputFile))) {
626                         map.put(fileArgsOpt.get(i), outputFilenameOpt);
627                     } else {
628                         throw new IllegalArgumentException(
629                                 "Input URL should be single and cannot have directory as output");
630                     }
631                 }
632             }
633         }
634 
635         return map;
636     }
637 
638     /**
639      * Builds the reader.
640      *
641      * @param filename
642      *            the filename
643      *
644      * @return the buffered reader
645      *
646      * @throws IOException
647      *             Signals that an I/O exception has occurred.
648      */
649     private BufferedReader buildReader(String filename) throws IOException {
650         if (filename == null) {
651             return new BufferedReader(new InputStreamReader(System.in, charsetOpt));
652         } else if (urlPattern.matcher(filename).matches()) {
653             return new BufferedReader(
654                     new InputStreamReader(URI.create(filename).toURL().openConnection().getInputStream(), charsetOpt));
655         } else {
656             return Files.newBufferedReader(Path.of(filename), charsetOpt);
657         }
658     }
659 
660     /**
661      * Builds the writer.
662      *
663      * @param filename
664      *            the filename
665      *
666      * @return the writer
667      *
668      * @throws IOException
669      *             Signals that an I/O exception has occurred.
670      */
671     private Writer buildWriter(String filename) throws IOException {
672         if (filename == null) {
673             return new OutputStreamWriter(System.out, charsetOpt);
674         } else {
675             return new OutputStreamWriter(Files.newOutputStream(Path.of(filename)), charsetOpt);
676         }
677     }
678 
679     /**
680      * Read resource.
681      *
682      * @param input
683      *            the input
684      *
685      * @return the string
686      *
687      * @throws IOException
688      *             Signals that an I/O exception has occurred.
689      */
690     private String readResource(BufferedReader input) throws IOException {
691         StringBuilder source = new StringBuilder();
692         try {
693             String line = null;
694             while ((line = input.readLine()) != null) {
695                 source.append(line);
696                 source.append(System.getProperty("line.separator"));
697             }
698         } finally {
699             closeStream(input);
700         }
701         return source.toString();
702     }
703 
704     /**
705      * Write resource.
706      *
707      * @param content
708      *            the content
709      * @param output
710      *            the output
711      *
712      * @throws IOException
713      *             Signals that an I/O exception has occurred.
714      */
715     private void writeResource(String content, Writer output) throws IOException {
716         try {
717             output.write(content);
718         } finally {
719             closeStream(output);
720         }
721     }
722 
723     /**
724      * Close stream.
725      *
726      * @param stream
727      *            the stream
728      */
729     private void closeStream(Closeable stream) {
730         if (stream != null) {
731             try {
732                 stream.close();
733             } catch (IOException e) {
734                 logger.trace("", e);
735             }
736         }
737     }
738 
739     /**
740      * Esc reg ex.
741      *
742      * @param inStr
743      *            the in str
744      *
745      * @return the string
746      */
747     private String escRegEx(String inStr) {
748         return inStr.replaceAll("([\\\\*+\\[\\](){}\\$.?\\^|])", "\\\\$1");
749     }
750 
751     /**
752      * Prints the usage.
753      */
754     private void printUsage() {
755         logger.info("""
756                 Usage: java -jar htmlcompressor.jar [options] [input]
757 
758                 [input]                        URL, filename, directory, or space separated list
759                                                of files and directories to compress.
760                                                If none provided reads from <stdin>
761 
762                 Global Options:
763                  -?, /?, -h, --help            Displays this help screen
764                  -t, --type <html|xml>         If not provided autodetects from file extension
765                  -r, --recursive               Process files inside subdirectories
766                  -c, --charset <charset>       Charset for reading files, UTF-8 by default
767                  -m, --mask <filemask>         Filter input files inside directories by mask
768                  -o, --output <path>           Filename or directory for compression results.
769                                                If none provided outputs result to <stdout>
770                  -a, --analyze                 Tries different settings and displays report.
771                                                All settings except --js-compressor are ignored
772 
773                 XML Compression Options:
774                  --preserve-comments           Preserve comments
775                  --preserve-intertag-spaces    Preserve intertag spaces
776 
777                 HTML Compression Options:
778                  --preserve-comments           Preserve comments
779                  --preserve-multi-spaces       Preserve multiple spaces
780                  --preserve-line-breaks        Preserve line breaks
781                  --remove-intertag-spaces      Remove intertag spaces
782                  --remove-quotes               Remove unneeded quotes
783                  --simple-doctype              Change doctype to <!DOCTYPE html>
784                  --remove-style-attr           Remove TYPE attribute from STYLE tags
785                  --remove-link-attr            Remove TYPE attribute from LINK tags
786                  --remove-script-attr          Remove TYPE and LANGUAGE from SCRIPT tags
787                  --remove-form-attr            Remove METHOD=\"GET\" from FORM tags
788                  --remove-input-attr           Remove TYPE=\"TEXT\" from INPUT tags
789                  --simple-bool-attr            Remove values from boolean tag attributes
790                  --remove-js-protocol          Remove \"javascript:\" from inline event handlers
791                  --remove-http-protocol        Remove \"http:\" from tag attributes
792                  --remove-https-protocol       Remove \"https:\" from tag attributes
793                  --remove-surrounding-spaces <min|max|all|custom_list>
794                                                Predefined or custom comma separated list of tags
795                  --compress-js                 Enable inline JavaScript compression
796                  --compress-css                Enable inline CSS compression using YUICompressor
797                  --js-compressor <yui|closure> Switch inline JavaScript compressor between
798                                                YUICompressor (default) and Closure Compiler
799 
800                 JavaScript Compression Options for YUI Compressor:
801                  --nomunge                     Minify only, do not obfuscate
802                  --preserve-semi               Preserve all semicolons
803                  --disable-optimizations       Disable all micro optimizations
804                  --line-break <column num>     Insert a line break after the specified column
805 
806                 JavaScript Compression Options for Google Closure Compiler:
807                  --closure-opt-level <simple|advanced|whitespace>
808                                                Sets level of optimization (simple by default)
809                  --closure-externs <file>      Sets custom externs file, repeat for each file
810                  --closure-custom-externs-only Disable default built-in externs
811 
812                 CSS Compression Options for YUI Compressor:
813                  --line-break <column num>     Insert a line break after the specified column
814 
815                 Custom Block Preservation Options:
816                  --preserve-php                Preserve <?php ... ?> tags
817                  --preserve-server-script      Preserve <% ... %> tags
818                  --preserve-ssi                Preserve <!--# ... --> tags
819                  -p, --preserve <path>         Read regular expressions that define
820                                                custom preservation rules from a file
821 
822                 Please note that if you enable CSS or JavaScript compression, additional
823                 YUI Compressor or Google Closure Compiler jar files must be present
824                 in the same directory as this jar.
825                 """);
826     }
827 
828     /**
829      * The Class CompressorFileFilter.
830      */
831     private class CompressorFileFilter implements FileFilter {
832 
833         /** The filemask pattern. */
834         private Pattern filemaskPattern;
835 
836         /** The with dirs. */
837         private boolean withDirs;
838 
839         /**
840          * Instantiates a new compressor file filter.
841          *
842          * @param type
843          *            the type
844          * @param filemask
845          *            the filemask
846          * @param withDirs
847          *            the with dirs
848          */
849         public CompressorFileFilter(String type, String filemask, boolean withDirs) {
850 
851             this.withDirs = withDirs;
852 
853             if (filemask == null) {
854                 if (type != null && "xml".equalsIgnoreCase(type)) {
855                     filemaskPattern = Pattern.compile("^.*\\.xml$", Pattern.CASE_INSENSITIVE);
856                 } else {
857                     filemaskPattern = Pattern.compile("^.*\\.html?$", Pattern.CASE_INSENSITIVE);
858                 }
859             } else {
860                 // turn mask into regexp
861                 filemask = filemask.replaceAll(escRegEx("."), Matcher.quoteReplacement("\\."));
862                 filemask = filemask.replaceAll(escRegEx("*"), Matcher.quoteReplacement(".*"));
863                 filemask = filemask.replaceAll(escRegEx("?"), Matcher.quoteReplacement("."));
864                 filemask = filemask.replaceAll(escRegEx(";"), Matcher.quoteReplacement("$|^"));
865                 filemask = "^" + filemask + "$";
866 
867                 filemaskPattern = Pattern.compile(filemask, Pattern.CASE_INSENSITIVE);
868             }
869         }
870 
871         @Override
872         public boolean accept(File file) {
873             if (!withDirs) {
874                 // take only matching non-dirs
875                 if (!file.isDirectory()) {
876                     return filemaskPattern.matcher(file.getName()).matches();
877                 }
878             } else {
879                 // take matching files and dirs
880                 return file.isDirectory() || filemaskPattern.matcher(file.getName()).matches();
881             }
882             return false;
883         }
884 
885     }
886 
887 }