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 }