001/** 002 * Copyright 2005-2016 The Kuali Foundation 003 * 004 * Licensed under the Educational Community License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.opensource.org/licenses/ecl2.php 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.kuali.rice.krad.theme.postprocessor; 017 018import org.apache.commons.io.IOUtils; 019import org.apache.commons.lang.StringUtils; 020import org.apache.log4j.Logger; 021import org.kuali.rice.krad.theme.util.ThemeBuilderConstants; 022import org.kuali.rice.krad.theme.util.ThemeBuilderUtils; 023 024import java.io.File; 025import java.io.FileOutputStream; 026import java.io.FileReader; 027import java.io.IOException; 028import java.io.InputStreamReader; 029import java.io.OutputStream; 030import java.io.OutputStreamWriter; 031import java.util.ArrayList; 032import java.util.Collection; 033import java.util.HashMap; 034import java.util.List; 035import java.util.Map; 036import java.util.Properties; 037 038/** 039 * Base class for the JS and CSS theme file processors (or post-processors) that act on a given theme 040 * 041 * <p> 042 * The post processing of JS and CSS files is orchestrated in this base class with the specific configuration 043 * for each provided through abstract methods. This {@link #process()} method performs the major calls of 044 * the process 045 * </p> 046 * 047 * <p> 048 * Base class also provides some helper methods such as {@link #getPropertyValueAsPluginDirs(java.lang.String)} 049 * and {@link #addMissingPluginDirs(java.util.List<java.io.File>)} 050 * </p> 051 * 052 * @author Kuali Rice Team (rice.collab@kuali.org) 053 */ 054public abstract class ThemeFilesProcessor { 055 private static final Logger LOG = Logger.getLogger(ThemeFilesProcessor.class); 056 057 protected static final String PLUGIN_FILES_KEY = "plugin"; 058 protected static final String SUBDIR_FILES_KEY = "subdir"; 059 060 protected String themeName; 061 protected File themeDirectory; 062 protected Properties themeProperties; 063 protected Map<String, File> themePluginDirsMap; 064 protected File workingDir; 065 protected String projectVersion; 066 067 public ThemeFilesProcessor(String themeName, File themeDirectory, Properties themeProperties, 068 Map<String, File> themePluginDirsMap, File workingDir, String projectVersion) { 069 this.themeName = themeName; 070 this.themeDirectory = themeDirectory; 071 this.themeProperties = themeProperties; 072 this.themePluginDirsMap = themePluginDirsMap; 073 this.workingDir = workingDir; 074 this.projectVersion = projectVersion; 075 } 076 077 /** 078 * Carries out the theme files post process 079 * 080 * <p> 081 * Processing of each file type includes the following: 082 * 083 * <ul> 084 * <li>Collect the theme files for each type</li> 085 * <li>Perform a sorting process on the files to form the correct order of sourcing or merging</li> 086 * <li>Merge all the files for the type into a single file. Here subclasses can perform alterations on 087 * the merged contents</li> 088 * <li>Minifiy the merged file using a compressor that is appropriate for the file type</li> 089 * <li>Write out the listing of files for the type to the theme's properties file</li> 090 * </ul> 091 * </p> 092 * 093 * <p> 094 * Any {@link IOException} that occur are caught and thrown as runtime exceptions 095 * </p> 096 */ 097 public void process() { 098 Map<String, List<File>> themeFilesMap = collectThemeFiles(); 099 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}