View Javadoc
1   /*
2    * Copyright 2014-2017 Mark Prins, GeoDienstenCentrum.
3    * Copyright 2010-2014 Jasig.
4    *
5    * See the NOTICE file distributed with this work for additional information
6    * regarding copyright ownership.
7    *
8    * Licensed under the Apache License, Version 2.0 (the "License");
9    * you may not use this file except in compliance with the License.
10   * You may obtain a copy of the License at
11   *
12   * http://www.apache.org/licenses/LICENSE-2.0
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS,
16   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17   * See the License for the specific language governing permissions and
18   * limitations under the License.
19   */
20  package nl.geodienstencentrum.maven.plugin.sass;
21  
22  import java.io.File;
23  import java.io.FileOutputStream;
24  import java.io.IOException;
25  import java.net.URI;
26  import java.net.URISyntaxException;
27  import java.net.URL;
28  import java.util.ArrayList;
29  import java.util.Arrays;
30  import java.util.Collections;
31  import java.util.Enumeration;
32  import java.util.HashMap;
33  import java.util.Iterator;
34  import java.util.List;
35  import java.util.Map;
36  import java.util.Map.Entry;
37  import java.util.jar.JarEntry;
38  import java.util.jar.JarFile;
39  
40  import org.apache.commons.io.FilenameUtils;
41  import org.apache.maven.model.FileSet;
42  import org.apache.maven.plugin.AbstractMojo;
43  import org.apache.maven.plugin.MojoExecutionException;
44  import org.apache.maven.plugin.MojoFailureException;
45  import org.apache.maven.plugin.logging.Log;
46  import org.apache.maven.plugins.annotations.Parameter;
47  import org.apache.maven.shared.utils.io.IOUtil;
48  import org.jruby.embed.LocalContextScope;
49  import org.jruby.embed.ScriptingContainer;
50  
51  import com.google.common.collect.ImmutableList;
52  import com.google.common.collect.ImmutableMap;
53  
54  import nl.geodienstencentrum.maven.plugin.sass.compiler.CompilerCallback;
55  
56  /**
57   * Base for batching Sass Mojos.
58   *
59   */
60  public abstract class AbstractSassMojo extends AbstractMojo {
61  
62  	private static final String BOURBON_GEM_PATH = "core";
63  	private static final String BOURBON_DEST_PATH = "bourbon";
64  
65  	/**
66  	 * Build directory for the plugin.
67  	 *
68  	 * @since 2.0
69  	 */
70  	@Parameter(defaultValue = "${project.build.directory}")
71  	protected File buildDirectory;
72  
73  	/**
74  	 * Where to put the compiled CSS files.
75  	 *
76  	 * @since 2.0
77  	 */
78  	@Parameter(defaultValue = "${project.build.directory}/${project.build.finalName}/css")
79  	protected File destination;
80  
81  	/**
82  	 * Fail the build if errors occur during compilation of sass/scss templates.
83  	 *
84  	 * @since 2.0
85  	 */
86  	@Parameter(defaultValue = "true")
87  	protected boolean failOnError;
88  
89  	/**
90  	 * Defines options for Sass::Plugin.options. See <a href=
91  	 * "http://sass-lang.com/documentation/file.SASS_REFERENCE.html#Options">Sass
92  	 * options</a> If the value is a string it must by quoted in the maven
93  	 * configuration: &lt;cache_location&gt;'/tmp/sass'&lt;/cache_location&gt;
94  	 *
95  	 * If no options are set the default configuration set is used which is:
96  	 *
97  	 * <pre>
98  	 * &lt;unix_newlines&gt;true&lt;/unix_newlines&gt;
99  	 * &lt;cache&gt;true&lt;/cache&gt;
100 	 * &lt;always_update&gt;true&lt;/always_update&gt;
101 	 * &lt;cache_location&gt;${project.build.directory}/sass_cache&lt;/cache_location&gt;
102 	 * &lt;style&gt;:expanded&lt;/style&gt;
103 	 * </pre>
104 	 *
105 	 * @since 2.0
106 	 */
107 	@Parameter
108 	private Map<String, String> sassOptions = new HashMap<String, String>(
109 	        ImmutableMap.of("unix_newlines", "true", "cache", "true",
110 	                "always_update", "true", "style", ":expanded"));
111 
112 	/**
113 	 * Sources for compilation with their destination directory containing Sass
114 	 * files. Allows for multiple resource sources and destinations. If
115 	 * specified it precludes the direct specification of
116 	 * sassSourceDirectory/relativeOutputDirectory/destination parameters.
117 	 *
118 	 * Example configuration:
119 	 *
120 	 * <pre>
121 	 *      &lt;resources&gt;
122 	 *        &lt;resource&gt;
123 	 *          &lt;source&gt;
124 	 *              &lt;directory&gt;${basedir}/src/main/webapp&lt;/directory&gt;
125 	 *              &lt;includes&gt;
126 	 *                  &lt;include&gt;**&#x0002F;*.scss&lt;/include&gt;
127 	 *              &lt;/includes&gt;
128 	 *          &lt;/source&gt;
129 	 *          &lt;relativeOutputDirectory&gt;..&lt;/relativeOutputDirectory&gt;
130 	 *          &lt;destination&gt;${project.build.directory}/${project.build.finalName}
131 	 *              &lt;/destination&gt;
132 	 *        &lt;/resource&gt;
133 	 *      &lt;/resources&gt;
134 	 * </pre>
135 	 *
136 	 * @since 2.0
137 	 */
138 	@Parameter
139 	private List<Resource> resources = Collections.emptyList();
140 
141 	/**
142 	 * Defines paths where jruby will look for gems. E.g. a maven build could
143 	 * download gems into ${project.build.directory}/rubygems and a gemPath
144 	 * pointed to this directory. Finally, individual gems can be loaded via the
145 	 * &lt;gems&gt; configuration.
146 	 *
147 	 * @since 2.0
148 	 */
149 	@Parameter(defaultValue = "${project.build.directory}/rubygems")
150 	private String[] gemPaths = new String[0];
151 
152 	/**
153 	 * Defines gems to be loaded before Sass. This is useful to add gems
154 	 * with
155      * custom Sass functions or stylesheets. Gems that hook into Sass	 * are transparently added to Sass' load_path.
156 	 *
157 	 * @since 2.0
158 	 */
159 	@Parameter
160 	private String[] gems = new String[0];
161 
162 	/**
163 	 * Enable the use of Bourbon style library mixins.
164 	 *
165 	 * @since 2.11
166 	 */
167 	@Parameter(defaultValue = "false")
168 	private boolean useBourbon;
169 
170 	/**
171 	 * Directory containing Sass files, defaults to the Maven
172 	 * sources directory (${basedir}/src/main/sass).
173 	 *
174 	 * @since 2.0
175 	 */
176 	@Parameter(defaultValue = "${basedir}/src/main/sass", property = "sassSourceDirectory")
177 	private File sassSourceDirectory;
178 
179 	/**
180 	 * Defines files in the source directories to include.
181 	 *
182 	 * Defaults to: {@code **&#x0002F;*.scss}
183 	 *
184 	 * @since 2.0
185 	 */
186 	@Parameter
187 	private String[] includes = new String[] { "**/*.scss" };
188 
189 	/**
190 	 * Defines which of the included files in the source directories to exclude
191 	 * (none by default).
192 	 *
193 	 * @since 2.0
194 	 */
195 	@Parameter
196 	private String[] excludes;
197 
198 	/**
199 	 * Defines an additional path section when calculating the destination for
200 	 * the SCSS file. Allows, for example
201 	 * "/media/skins/universality/coal/scss/portal.scss" to end up at
202 	 * "/media/skins/universality/coal/portal.css" by specifying ".."
203 	 * <strong>NB</strong>This location is relative to the source {@link #sassSourceDirectory}
204 	 *
205 	 * @since 2.0
206 	 */
207 	@Parameter(defaultValue = "..")
208 	private String relativeOutputDirectory;
209 
210 	/**
211 	 * skip execution.
212 	 *
213 	 * @since 2.10
214 	 */
215 	@Parameter(defaultValue = "false")
216 	private boolean skip;
217 
218 	/**
219 	 * Execute the Sass Compilation Ruby Script.
220 	 *
221 	 * @param sassScript
222 	 *            the sass script
223 	 * @throws MojoExecutionException
224 	 *             the mojo execution exception
225 	 * @throws MojoFailureException
226 	 *             the mojo failure exception
227 	 */
228 	protected void executeSassScript(final String sassScript)
229             throws MojoExecutionException, MojoFailureException {
230 		if (this.skip) {
231 			return;
232 		}
233 
234 		final Log log = this.getLog();
235 		log.debug("Execute Sass Ruby script:\n\n" + sassScript + "\n\n");
236 
237 		final ScriptingContainer scriptingContainer = new ScriptingContainer(LocalContextScope.SINGLETHREAD);
238 		final CompilerCallbackler/CompilerCallback.html#CompilerCallback">CompilerCallback compilerCallback = new CompilerCallback(log);
239 
240 		scriptingContainer.setHomeDirectory("uri:classloader://META-INF/jruby.home");
241 		scriptingContainer.put("$compiler_callback", compilerCallback);
242 		Object o = scriptingContainer.runScriptlet(sassScript);
243 
244 		if (this.failOnError && compilerCallback.hadError()) {
245 			throw new MojoFailureException(
246 			   "Sass compilation encountered errors (see above for details).");
247 		}
248 		log.debug("\n");
249 		scriptingContainer.terminate();
250 	}
251 
252 	/**
253 	 * Builds the basic sass script.
254 	 *
255 	 * @param sassScript
256 	 *            the sass script
257 	 * @throws MojoExecutionException
258 	 *             the mojo execution exception
259 	 */
260 	protected void buildBasicSassScript(final StringBuilder sassScript)
261 	        throws MojoExecutionException {
262 		final Log log = this.getLog();
263 
264 		sassScript.append("require 'rubygems'\n");
265 		if (this.gemPaths.length > 0) {
266 			sassScript.append("env = { 'GEM_PATH' => [\n");
267 			for (final String gemPath : this.gemPaths) {
268 				sassScript.append("    '").append(gemPath).append("',\n");
269 			}
270 
271 			final String gemPath = System.getenv("GEM_PATH");
272 			if (gemPath != null) {
273 				for (final String p : gemPath.split(File.pathSeparator)) {
274 					sassScript.append("    '").append(p).append("',\n");
275 				}
276 			}
277 			/* remove trailing comma+\n */
278 			sassScript.setLength(sassScript.length() - 2);
279 			// TODO
280 			// quick fix for the deprecation message coming from Gem.paths, this should be cleaned up;
281 			// there's a round trip of splitting into array in java and then unsplitting the array in ruby...
282 			// see #118
283 			sassScript.append("\n].uniq.join(File::PATH_SEPARATOR) }\n");
284 			sassScript.append("Gem.paths = env\n");
285 		}
286 
287 		for (final String gem : this.gems) {
288 			sassScript.append("require '").append(gem).append("'\n");
289 		}
290 
291 		sassScript.append("require 'sass/plugin'\n");
292 		sassScript.append("require 'java'\n");
293 
294 		// Get all template locations from resources and set option
295 		// 'template_location' and
296 		// 'css_location' (to override default "./public/stylesheets/sass",
297 		// "./public/stylesheets")
298 		// remaining locations are added later with 'add_template_location'
299 		final Iterator<Entry<String, String>> templateLocations = this
300 		        .getTemplateLocations();
301 		if (templateLocations.hasNext()) {
302 			final Entry<String, String> location = templateLocations.next();
303 			this.sassOptions.put("template_location",
304 			                     "'" + location.getKey() + "'");
305 			this.sassOptions.put("css_location",
306 			                     "'" + location.getValue() + "'");
307 		}
308 
309 		// If not explicitly set place the cache location in the target dir
310 		if (!this.sassOptions.containsKey("cache_location")) {
311 			final File sassCacheDir = new File(this.buildDirectory,
312 			        "sass_cache");
313 			final String sassCacheDirStr = sassCacheDir.toString();
314 			this.sassOptions.put("cache_location",
315 			        "'" + FilenameUtils.separatorsToUnix(sassCacheDirStr) + "'");
316 		}
317 
318 		// Add the plugin configuration options
319 		sassScript.append("Sass::Plugin.options.merge!(\n");
320 		for (final Iterator<Entry<String, String>> entryItr = this.sassOptions
321 		        .entrySet().iterator(); entryItr.hasNext();) {
322 			final Entry<String, String> optEntry = entryItr.next();
323 			final String opt = optEntry.getKey();
324 			final String value = optEntry.getValue();
325 			sassScript.append("    :").append(opt).append(" => ").append(value);
326 			if (entryItr.hasNext()) {
327 				sassScript.append(",");
328 			}
329 			sassScript.append('\n');
330 		}
331 		sassScript.append(")\n");
332 
333 		// add remaining template locations with 'add_template_location' (need
334 		// to be done after options.merge)
335 		while (templateLocations.hasNext()) {
336 			final Entry<String, String> location = templateLocations.next();
337 			sassScript.append("Sass::Plugin.add_template_location('")
338 			        .append(location.getKey()).append("', '")
339 			        .append(location.getValue()).append("')\n");
340 		}
341 
342 		if (this.useBourbon) {
343 			log.info("Running with Bourbon enabled.");
344 			final String bDest = this.buildDirectory + "/bourbon-lib";
345 			this.extractBourbonResources(bDest);
346 			sassScript.append("Sass::Plugin.add_template_location('")
347                     .append(bDest)
348                     .append("', '")
349                     .append(destination).append("')\n");
350 		}
351 
352 		// set up sass compiler callback for reporting
353 		sassScript
354 		        .append("Sass::Plugin.on_compilation_error {|error, template, css| $compiler_callback.compilationError(error.message, template, css) }\n");
355 		sassScript
356 		        .append("Sass::Plugin.on_updated_stylesheet {|template, css| $compiler_callback.updatedStylesheeet(template, css) }\n");
357 		sassScript
358 		        .append("Sass::Plugin.on_template_modified {|template| $compiler_callback.templateModified(template) }\n");
359 		sassScript
360 		        .append("Sass::Plugin.on_template_created {|template| $compiler_callback.templateCreated(template) }\n");
361 		sassScript
362 		        .append("Sass::Plugin.on_template_deleted {|template| $compiler_callback.templateDeleted(template) }\n");
363 
364 		// make ruby give use some debugging info when requested
365 		if (log.isDebugEnabled()) {
366 			sassScript.append("require 'pp'\n");
367 			sassScript.append("pp Sass::Plugin.options\n");
368 		}
369 	}
370 
371 	/**
372 	 * Gets the template locations.
373 	 *
374 	 * @return the template locations
375 	 */
376 	private Iterator<Entry<String, String>> getTemplateLocations() {
377 		final Log log = this.getLog();
378 		List<Resource> resList = this.resources;
379 
380 		if (resList.isEmpty()) {
381 			log.info("No resource element was specified, using short configuration.");
382 			// If no resources specified, create a resource based on the other
383 			// parameters and defaults
384 			final Resourcelugin/sass/Resource.html#Resource">Resource resource = new Resource();
385 			resource.source = new FileSet();
386 
387 			if (this.sassSourceDirectory != null) {
388 				log.debug("Setting source directory: "
389 						+ this.sassSourceDirectory.toString());
390 				resource.source.setDirectory(this.sassSourceDirectory
391 				        .toString());
392 			} else {
393 				log.error("\"" + this.sassSourceDirectory
394 				        + "\" is not a directory.");
395 				resource.source.setDirectory("./src/main/sass");
396 			}
397 			if (this.includes != null) {
398 				log.debug("Setting includes: " + Arrays.toString(this.includes));
399 				resource.source.setIncludes(Arrays.asList(this.includes));
400 			}
401 			if (this.excludes != null) {
402 				log.debug("Setting excludes: " + Arrays.toString(this.excludes));
403 				resource.source.setExcludes(Arrays.asList(this.excludes));
404 			}
405 			resource.relativeOutputDirectory = this.relativeOutputDirectory;
406 			resource.destination = this.destination;
407 			resList = ImmutableList.of(resource);
408 		}
409 
410 		final List<Entry<String, String>> locations = new ArrayList<Entry<String, String>>();
411 		for (final Resource source : resList) {
412 			for (final Entry<String, String> entry : source
413 			        .getDirectoriesAndDestinations(log).entrySet()) {
414 				log.info("Queueing Sass template for compile: "
415 				        + entry.getKey() + " => " + entry.getValue());
416 				locations.add(entry);
417 			}
418 		}
419 		return locations.iterator();
420 	}
421 
422 	/**
423 	 * Extract the Bourbon assets to the build directory.
424 	 * @param destinationDir directory for the Bourbon resources
425 	 */
426 	private synchronized void extractBourbonResources(String destinationDir) {
427 		final Log log = this.getLog();
428 		try {
429 			File destDir = new File(destinationDir);
430 			if (destDir.isDirectory()) {
431 				// skip extracting Bourbon, as it seems to hav been done
432 				log.info("Bourbon resources seems to have been extracted before.");
433 				return;
434 			}
435 			log.info("Extracting Bourbon resources to: " + destinationDir);
436 			destDir.mkdirs();
437 			// find the jar with the Bourbon directory in the classloader
438 			URL urlJar = this.getClass().getClassLoader().getResource("scss-report.xsl");
439 			String resourceFilePath = urlJar.getFile();
440 			int index = resourceFilePath.indexOf("!");
441 			String jarFileURI = resourceFilePath.substring(0, index);
442 			File jarFile = new File(new URI(jarFileURI));
443 			JarFile jar = new JarFile(jarFile);
444 
445 			// extract bourbon to destinationDir
446 			for (Enumeration<JarEntry> enums = jar.entries(); enums.hasMoreElements();) {
447 				JarEntry entry = enums.nextElement();
448 
449 				if (entry.getName().contains(BOURBON_GEM_PATH) || entry.getName().contains("_bourbon.scss")) {
450 					// shorten the path a bit
451 					index = entry.getName().indexOf(BOURBON_GEM_PATH);
452 					String fileName = destinationDir + File.separator + entry.getName().substring(index);
453 
454 					File f = new File(fileName.replace(BOURBON_GEM_PATH,BOURBON_DEST_PATH));
455 					if (fileName.endsWith("/")) {
456 						log.debug("create Bourbon directory: " + f);
457 						f.mkdirs();
458 					} else {
459 						FileOutputStream fos = new FileOutputStream(f);
460 						try {
461 							// log.debug("  extract Bourbon file " + entry.getName() + " to " + f);
462 							IOUtil.copy(jar.getInputStream(entry), fos);
463 						} finally {
464 							IOUtil.close(fos);
465 						}
466 					}
467 				}
468 			}
469 		} catch (IOException | URISyntaxException ex) {
470 			log.error("Error extracting Bourbon resources.", ex);
471 		}
472 	}
473 
474 	/**
475 	 * Resources accessor.
476 	 * @return the resources
477 	 */
478 	protected List<Resource> getResources() {
479 		return this.resources;
480 	}
481 
482 	/**
483 	 * skip accessor.
484 	 * @return whether to skip execution or not
485 	 */
486 	protected boolean isSkip() {
487 		return this.skip;
488 	}
489 
490 	/**
491 	 * Sass sources directory accessor.
492 	 * @return the sassSourceDirectory
493 	 */
494 	protected File getSassSourceDirectory() {
495 		return this.sassSourceDirectory;
496 	}
497 }