View Javadoc
1   /**
2    * Copyright 2005-2014 The Kuali Foundation
3    *
4    * Licensed under the Educational Community 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.opensource.org/licenses/ecl2.php
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 org.kuali.rice.krad.theme.postprocessor;
17  
18  import org.apache.commons.io.IOUtils;
19  import org.apache.commons.lang.StringUtils;
20  import org.apache.log4j.Logger;
21  import org.kuali.rice.krad.theme.util.ThemeBuilderConstants;
22  import org.kuali.rice.krad.theme.util.ThemeBuilderUtils;
23  
24  import java.io.File;
25  import java.io.FileOutputStream;
26  import java.io.FileReader;
27  import java.io.IOException;
28  import java.io.InputStreamReader;
29  import java.io.OutputStream;
30  import java.io.OutputStreamWriter;
31  import java.util.ArrayList;
32  import java.util.Collection;
33  import java.util.HashMap;
34  import java.util.List;
35  import java.util.Map;
36  import java.util.Properties;
37  
38  /**
39   * Base class for the JS and CSS theme file processors (or post-processors) that act on a given theme
40   *
41   * <p>
42   * The post processing of JS and CSS files is orchestrated in this base class with the specific configuration
43   * for each provided through abstract methods. This {@link #process()} method performs the major calls of
44   * the process
45   * </p>
46   *
47   * <p>
48   * Base class also provides some helper methods such as {@link #getPropertyValueAsPluginDirs(java.lang.String)}
49   * and {@link #addMissingPluginDirs(java.util.List<java.io.File>)}
50   * </p>
51   *
52   * @author Kuali Rice Team (rice.collab@kuali.org)
53   */
54  public abstract class ThemeFilesProcessor {
55      private static final Logger LOG = Logger.getLogger(ThemeFilesProcessor.class);
56  
57      protected static final String PLUGIN_FILES_KEY = "plugin";
58      protected static final String SUBDIR_FILES_KEY = "subdir";
59  
60      protected String themeName;
61      protected File themeDirectory;
62      protected Properties themeProperties;
63      protected Map<String, File> themePluginDirsMap;
64      protected File workingDir;
65      protected String projectVersion;
66  
67      public ThemeFilesProcessor(String themeName, File themeDirectory, Properties themeProperties,
68              Map<String, File> themePluginDirsMap, File workingDir, String projectVersion) {
69          this.themeName = themeName;
70          this.themeDirectory = themeDirectory;
71          this.themeProperties = themeProperties;
72          this.themePluginDirsMap = themePluginDirsMap;
73          this.workingDir = workingDir;
74          this.projectVersion = projectVersion;
75      }
76  
77      /**
78       * Carries out the theme files post process
79       *
80       * <p>
81       * Processing of each file type includes the following:
82       *
83       * <ul>
84       * <li>Collect the theme files for each type</li>
85       * <li>Perform a sorting process on the files to form the correct order of sourcing or merging</li>
86       * <li>Merge all the files for the type into a single file. Here subclasses can perform alterations on
87       * the merged contents</li>
88       * <li>Minifiy the merged file using a compressor that is appropriate for the file type</li>
89       * <li>Write out the listing of files for the type to the theme's properties file</li>
90       * </ul>
91       * </p>
92       *
93       * <p>
94       * Any {@link IOException} that occur are caught and thrown as runtime exceptions
95       * </p>
96       */
97      public void process() {
98          Map<String, List<File>> themeFilesMap = collectThemeFiles();
99  
100         // perform any custom sorting configured in the theme properties
101         List<File> themeFiles = sortThemeFiles(themeFilesMap.get(PLUGIN_FILES_KEY), themeFilesMap.get(
102                 SUBDIR_FILES_KEY));
103 
104         File mergedFile = createMergedFile(false);
105         try {
106             mergeFiles(themeFiles, mergedFile);
107         } catch (IOException e) {
108             throw new RuntimeException("Exception encountered while merging files for type: "
109                     + getFileTypeExtension());
110         }
111 
112         File minifiedFile = createMergedFile(true);
113         try {
114             minify(mergedFile, minifiedFile);
115         } catch (IOException e) {
116             throw new RuntimeException("Exception encountered while minifying files for type: "
117                     + getFileTypeExtension());
118         }
119 
120         // add listing of file paths for this type to the theme properties
121         // (to be read in dev mode by the view theme)
122         List<String> themeFilePaths = ThemeBuilderUtils.getRelativePaths(this.workingDir, themeFiles);
123 
124         this.themeProperties.put(getFileListingConfigKey(), StringUtils.join(themeFilePaths, ","));
125     }
126 
127     /**
128      * Collects the file names to include for the theme, separated by whether they come from a plugin directory
129      * or the theme directory
130      *
131      * <p>
132      * First all plugin directories that are included for the theme are listed based on the include for that
133      * file type {@link #getFileIncludes()}. Individual plugin files can be excluded with the property
134      * <code>pluginFileExcludes</code>, or the global excludes for the file type {@link #getFileExcludes()}
135      *
136      * Next the subdirectory of the theme that holds the file type, given by {@link #getFileTypeSubDirectory()},
137      * is listed to pick up include files. Again the global file includes and excludes for the type is used
138      *
139      * Finally, subclasses can add additional files by implementing {@link #addAdditionalFiles(java.util.List)}
140      * </p>
141      *
142      * @return map containing an entry for plugin file names, and theme file names. Keys are given by
143      *         {@link #PLUGIN_FILES_KEY} and {@link #SUBDIR_FILES_KEY}
144      * @see #getFileIncludes()
145      * @see #getFileExcludes()
146      * @see #getFileTypeSubDirectory()
147      * @see #addAdditionalFiles(java.util.List)
148      */
149     protected Map<String, List<File>> collectThemeFiles() {
150         Map<String, List<File>> themeFiles = new HashMap<String, List<File>>();
151 
152         String[] fileIncludes = getFileIncludes();
153         String[] fileExcludes = getFileExcludes();
154 
155         // add files from plugins first
156         String[] pluginFileExcludes = null;
157         if (this.themeProperties.containsKey(ThemeBuilderConstants.ThemeConfiguration.PLUGIN_FILE_EXCLUDES)) {
158             pluginFileExcludes = ThemeBuilderUtils.getPropertyValueAsArray(
159                     ThemeBuilderConstants.ThemeConfiguration.PLUGIN_FILE_EXCLUDES, this.themeProperties);
160         }
161 
162         // global file excludes should also apply to plugins
163         if (fileExcludes != null) {
164             if (pluginFileExcludes == null) {
165                 pluginFileExcludes = fileExcludes;
166             } else {
167                 pluginFileExcludes = ThemeBuilderUtils.addToArray(pluginFileExcludes, fileExcludes);
168             }
169         }
170 
171         // for convenience we don't require the extension on the patterns, but it must be added before
172         // we use the patterns for matching
173         ThemeBuilderUtils.addExtensionToPatterns(fileIncludes, getFileTypeExtension());
174         ThemeBuilderUtils.addExtensionToPatterns(fileExcludes, getFileTypeExtension());
175         ThemeBuilderUtils.addExtensionToPatterns(pluginFileExcludes, getFileTypeExtension());
176 
177         List<File> pluginThemeFiles = new ArrayList<File>();
178         for (Map.Entry<String, File> pluginMapping : this.themePluginDirsMap.entrySet()) {
179             String pluginName = pluginMapping.getKey();
180             File pluginDirectory = pluginMapping.getValue();
181 
182             // adjust plugin file excludes to not include the top directory
183             String[] adjustedFileExcludes = null;
184             if (pluginFileExcludes != null) {
185                 adjustedFileExcludes = new String[pluginFileExcludes.length];
186 
187                 for (int i = 0; i < pluginFileExcludes.length; i++) {
188                     adjustedFileExcludes[i] = StringUtils.removeStart(pluginFileExcludes[i], pluginName + "/");
189                 }
190             }
191 
192             pluginThemeFiles.addAll(ThemeBuilderUtils.getDirectoryFiles(pluginDirectory, fileIncludes,
193                     adjustedFileExcludes));
194         }
195 
196         themeFiles.put(PLUGIN_FILES_KEY, pluginThemeFiles);
197 
198         // now add files in the subdirectory for this file type directory
199         List<File> subDirThemeFiles = new ArrayList<File>();
200 
201         subDirThemeFiles.addAll(ThemeBuilderUtils.getDirectoryFiles(getFileTypeSubDirectory(), fileIncludes,
202                 fileExcludes));
203 
204         // allow additional files to be added based on the file type
205         addAdditionalFiles(subDirThemeFiles);
206 
207         themeFiles.put(SUBDIR_FILES_KEY, subDirThemeFiles);
208 
209         if (LOG.isDebugEnabled()) {
210             LOG.debug("Found " + subDirThemeFiles.size() + "file(s) for theme " + this.themeName);
211         }
212 
213         return themeFiles;
214     }
215 
216     /**
217      * Builds array of patterns used to find files to include for the type, by default picks up all
218      * files that have the extension for the type being processed
219      *
220      * @return array of string patterns to include
221      * @see #getFileTypeExtension()
222      */
223     protected String[] getFileIncludes() {
224         return new String[] {"**/*" + getFileTypeExtension()};
225     }
226 
227     /**
228      * Builds array of patterns used to exclude files to include for the type
229      *
230      * <p>
231      * Each file type has a configuration property where exclude patterns can be listed. This property
232      * key is retrieved by {@link #getExcludesConfigKey()}, and then split by the comma to get the array
233      * of patterns
234      * </p>
235      *
236      * @return array of string patterns to exclude
237      * @see #getExcludesConfigKey();
238      */
239     protected String[] getFileExcludes() {
240         String[] excludes = null;
241 
242         if (this.themeProperties.containsKey(getExcludesConfigKey())) {
243             String excludesString = this.themeProperties.getProperty(getExcludesConfigKey());
244 
245             excludes = excludesString.split(",");
246         }
247 
248         return excludes;
249     }
250 
251     /**
252      * Returns the File object that points to the theme subdirectory that contains files for the
253      * file type
254      *
255      * <p>
256      * Sub directory is formed by finding the directory with name {@link #getFileTypeDirectoryName()} within
257      * the theme directory
258      * </p>
259      *
260      * @return sub directory for the file type
261      * @see #getFileTypeDirectoryName()
262      */
263     protected File getFileTypeSubDirectory() {
264         File subDirectory = new File(this.themeDirectory, getFileTypeDirectoryName());
265 
266         if (!subDirectory.exists()) {
267             throw new RuntimeException(
268                     "Directory for file type " + getFileTypeDirectoryName() + " does not exist for theme: "
269                             + this.themeName);
270         }
271 
272         return subDirectory;
273     }
274 
275     /**
276      * Creates a new file that will hold the merged or minified contents
277      *
278      * <p>
279      * The merged file name is constructed by taking the theme name, concatenated with "." and the project version.
280      * To form the minified file name, the min suffix ".min" is appended to the merged file name
281      * </p>
282      *
283      * @param minified indicates whether to add the minified suffix
284      * @return file object pointing to the merged or minified file
285      */
286     protected File createMergedFile(boolean minified) {
287         String mergedFileName = this.themeName + "." + this.projectVersion;
288 
289         if (minified) {
290             mergedFileName += ThemeBuilderConstants.MIN_FILE_SUFFIX;
291         }
292 
293         mergedFileName += getFileTypeExtension();
294 
295         return new File(getFileTypeSubDirectory(), mergedFileName);
296     }
297 
298     /**
299      * Merges the content from the list of files into the given merge file
300      *
301      * <p>
302      * Contents are read for each file in the order they appear in the files list. Before adding the contents
303      * to the merged file, the method {@link #processMergeFileContents(java.lang.String, java.io.File, java.io.File)}
304      * is invoked to allow subclasses to alter the contents
305      * </p>
306      *
307      * @param filesToMerge list of files whose content should be merged
308      * @param mergedFile file that should receive the merged content
309      * @throws IOException
310      */
311     protected void mergeFiles(List<File> filesToMerge, File mergedFile) throws IOException {
312         OutputStream out = null;
313         OutputStreamWriter outWriter = null;
314         InputStreamReader reader = null;
315 
316         LOG.info("Creating merged file: " + mergedFile.getPath());
317 
318         try {
319             out = new FileOutputStream(mergedFile);
320             outWriter = new OutputStreamWriter(out);
321 
322             for (File fileToMerge : filesToMerge) {
323                 reader = new FileReader(fileToMerge);
324 
325                 String fileContents = IOUtils.toString(reader);
326                 if ((fileContents == null) || "".equals(fileContents)) {
327                     continue;
328                 }
329 
330                 fileContents = processMergeFileContents(fileContents, fileToMerge, mergedFile);
331 
332                 outWriter.append(fileContents);
333                 outWriter.flush();
334             }
335         } finally {
336             if (out != null) {
337                 out.close();
338             }
339 
340             if (reader != null) {
341                 reader.close();
342             }
343         }
344     }
345 
346     /**
347      * Extension (ex. 'css') for the file type being processed
348      *
349      * @return file type extension
350      */
351     protected abstract String getFileTypeExtension();
352 
353     /**
354      * Name of the directory relative to the theme directory which contains files for the type
355      *
356      * @return directory name
357      */
358     protected abstract String getFileTypeDirectoryName();
359 
360     /**
361      * Key for the property within the theme's properties file that can be configured to exlcude files
362      * of the type being processed
363      *
364      * @return property key for file type excludes
365      */
366     protected abstract String getExcludesConfigKey();
367 
368     /**
369      * Key for the property that will be written to the theme derived properties to list the included files
370      * for the file type
371      *
372      * @return property key for file type listing
373      */
374     protected abstract String getFileListingConfigKey();
375 
376     /**
377      * Invoked during the collection of files to allow additional files to be added to the theme's list
378      *
379      * @param themeFiles list of additional files to included for the theme
380      */
381     protected abstract void addAdditionalFiles(List<File> themeFiles);
382 
383     /**
384      * Invoked to build the final sorted list of files for the type, files from plugins and from the theme's
385      * sub directory are passed separately so special treatment can be given to those for sorting
386      *
387      * @param pluginFiles list of files that will be included and come from a plugin directory
388      * @param subDirFiles list of files that will be included and come from the theme subdirectory
389      * @return list of all files to include for the theme in the correct source order
390      */
391     protected abstract List<File> sortThemeFiles(List<File> pluginFiles, List<File> subDirFiles);
392 
393     /**
394      * Invoked during the merge process to alter the given file contents before they are appended to the
395      * merge file
396      *
397      * @param fileContents contents of the file that will be added
398      * @param fileToMerge file the contents were pulled from
399      * @param mergedFile file receiving the merged contents
400      * @return file contents to merge (possibly altered)
401      * @throws IOException
402      */
403     protected abstract String processMergeFileContents(String fileContents, File fileToMerge, File mergedFile)
404             throws IOException;
405 
406     /**
407      * Invoked after the merged file has been created to create the minified version
408      *
409      * @param mergedFile file containing the merged contents
410      * @param minifiedFile file created to receive the minified contents
411      * @throws IOException
412      */
413     protected abstract void minify(File mergedFile, File minifiedFile) throws IOException;
414 
415     /**
416      * Helper method that retrieves the value for the given property from the theme's properties as
417      * a list of strings
418      *
419      * @param propertyKey key for the property to retrieve the value for
420      * @return list of string values parsed from the property value
421      */
422     protected List<String> getThemePropertyValue(String propertyKey) {
423         return ThemeBuilderUtils.getPropertyValueAsList(propertyKey, this.themeProperties);
424     }
425 
426     /**
427      * Helper method that retrieves the value for the given property from the theme's properties as a
428      * list of file objects that point to plugin directories
429      *
430      * @param propertyKey key for the property to retrieve the value for
431      * @return list of files (plugin directories) parsed from the property value
432      */
433     protected List<File> getPropertyValueAsPluginDirs(String propertyKey) {
434         List<File> pluginDirs = null;
435 
436         List<String> pluginNames = ThemeBuilderUtils.getPropertyValueAsList(propertyKey, this.themeProperties);
437 
438         if (pluginNames != null && !pluginNames.isEmpty()) {
439             pluginDirs = new ArrayList<File>();
440 
441             for (String pluginName : pluginNames) {
442                 pluginName = pluginName.toLowerCase();
443 
444                 if (!this.themePluginDirsMap.containsKey(pluginName)) {
445                     throw new RuntimeException(
446                             "Invalid plugin name: " + pluginName + " in configuration for property " + propertyKey);
447                 }
448 
449                 pluginDirs.add(this.themePluginDirsMap.get(pluginName));
450             }
451         }
452 
453         return pluginDirs;
454     }
455 
456     /**
457      * Helper method to add any plugin directories for the theme being processed to the given list if they
458      * are not already contained in the list
459      *
460      * @param pluginList list of plugin directories to complete
461      * @return list of plugin directories that includes all plugins for the theme
462      */
463     protected List<File> addMissingPluginDirs(List<File> pluginList) {
464         List<File> allPluginDirs = new ArrayList<File>();
465 
466         if (pluginList != null) {
467             allPluginDirs.addAll(pluginList);
468         }
469 
470         Collection<File> allPlugins = this.themePluginDirsMap.values();
471         for (File pluginDir : allPlugins) {
472             if (!allPluginDirs.contains(pluginDir)) {
473                 allPluginDirs.add(pluginDir);
474             }
475         }
476 
477         return allPluginDirs;
478     }
479 }