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