Integrating Compass Style/SASS into Tapestry

24/8/2012

Why SASS, why Compass?

As we got started on our new baby, Flo, my co-founder, once again evaluated the bleeding edge of languages that compile into CSS, and found SASS to have some features that the more established LESS does not. He ended up choosing the Compass Style framework which builds on SASS for our stylesheets.

I had mixed feelings about this as it added a dependency on a Ruby script to our build pipeline, so I thought maybe we could find a nice way to integrate Compass support into our application.

Running Ruby during the build

The easiest thing to do is to just call Ruby on the command line to run compass during the build. This is simple enough to do with an Ant or Maven-based build. We just used the Exec Maven plugin to do this:

<plugin>
    <artifactId>exec-maven-plugin</artifactId>
    <groupId>org.codehaus.mojo</groupId>
    <executions>
       <execution>
         <id>Compass-Style</id>
         <phase>process-resources</phase>
         <goals>
          <goal>exec</goal>
         </goals>
         <configuration>
          <executable>compass</executable>
          <workingDirectory>
                      ${basedir}/path/to/sass/files
                 </workingDirectory>
           <arguments>
              <argument>compile</argument>
              <argument>
                       --css-dir=${project.build.directory}/path/to/css/output
                    </argument>
          </arguments>
         </configuration>
       </execution>
    </executions>
</plugin>

This of course requires that you install Ruby and Compass on each of the machines that run the build, and also that you update all of them when you update to a later version.

Now wouldn't it be nice if we could run SASS and Compass without all this installation effort? It turns out that we can.

Running Compass with JRuby

The first step to removing the dependency on Ruby is to switch to JRuby. JRuby is a complete Ruby implementation on the JVM, and in general runs any Ruby script without a problem. You can install JRuby like you normally would install Ruby and run it like that, or you can use the jruby-complete JAR instead, like this:

java -jar jruby-complete-1.6.7.2.jar -S compass

Packaging Compass inside a JAR

Now we are executing on the JVM, but we haven't won anything yet, as we're still running the usual, locally installed Ruby Gems. What we want to do is to package the gems into a portable JAR file which we can run on any machine that needs to package our application.

In principle, this is fairly easy to do. I loosely followed Nick Sieger's blog post to install the gems into a local directory and then package that directory into a JAR file.

JRuby has quite a brilliant abstraction from the file-based way of all things Ruby, so most things keep working when packaged into a JAR. A file inside a JAR has a path roughly like this: /path/to/jar/gems.jar!file/in/jar/script.rb. JRuby keeps relative files and everything working when using JARs, with one great BUT: There is no way to do such a thing as a directory listing inside a JAR file. That's right, everything that reads directory listings is now broken.

It turns out there are a few places in Compass and SASS that we need to monkeypatch to get them to run inside a JAR. The Compass version we're running is 0.12.2, which is still current at the time of writing. We need to make changes to three files.

First, compass discovers the available frameworks (i.e. compass and blueprint) by scanning the frameworks directory. This does no longer work in a JAR, resulting in the frameworks not being available.

We need to add an extra line for each framework to register it manually with Compass. I opted for editing the frameworks.rb file and added these two lines at the end:

Compass::Frameworks.discover(:defaults)
# end regular code, begin monkeypatch
Compass::Frameworks.register_directory(File.join(Compass.base_directory, 'frameworks/compass'))
Compass::Frameworks.register_directory(File.join(Compass.base_directory, 'frameworks/blueprint'))

In configuration/adapters.rb, there is a directory check in sass_load_paths. In a JAR, it always fails, and we need to disable it:

    def sass_load_paths
        load_paths = []
        load_paths << sass_path if sass_path
        Compass::Frameworks::ALL.each do |f|
          # monkey patch in the following line
          load_paths << f.stylesheets_directory
        end

        load_paths += resolve_additional_import_paths
        load_paths.map! do |p|
          next p if p.respond_to?(:find_relative)
          Sass::Importers::Filesystem.new(p.to_s)
        end
        load_paths << Compass::SpriteImporter.new
       load_paths
      end

Finally, there is one change we needed to make in SASS. (We're using 3.2.0.alpha.261.) It's trying to normalize the path to a file, which doesn't work in a JAR.

We're editing find_real_file() in importers/filesystem.rb:

def find_real_file(dir, name)
    for (f,s) in possible_files(remove_root(name))
      path = (dir == "." || Pathname.new(f).absolute?) ? f : "#{dir}/#{f}"
      #begin monkeypatch here
      if full_path = Dir[path].first
        full_path.gsub!(REDUNDANT_DIRECTORY,File::SEPARATOR)
        return full_path, s
      end
    end
    nil
  end

When we package the results of our changes as a JAR, we can now actually run it:

java -jar jruby-complete-1.6.7.2.jar -rcompass-gems.jar -S compass

We did a lot of nasty things, but we're still stuck on the command line. How about we integrate Compass into our web framework of choice?

Integrating with Tapestry 5.3

We're running Tapestry 5.3, which has a mechanism to include resources with components via the @Import annotation:

@Import(stylesheet = "css/loggedIn.css")
public class LoggedInLayout { ... }

Now what if we could just use our sass or scss files intead of the compiled CSS files, like this:

@Import(stylesheet = "css/loggedIn.scss")
public class LoggedInLayout { ... }

Tapestry has a pipeline for transforming resources that compile into JS and CSS that we can hook into. To do this, we need to implement our own ResourceTransformer:

public class CompassResourceTransformer implements ResourceTransformer {

    private static final Logger LOGGER = LoggerFactory.getLogger(
             CompassResourceTransformer.class);

    private final CompassCompiler compiler;

    public CompassResourceTransformer(boolean productionMode) {
       this.compiler = new CompassCompiler(productionMode);
    }

    public InputStream transform(final Resource source,
         final ResourceDependencies dependencies) throws IOException {
       LOGGER.info("Compiling " + source.getPath() + "...");
       final InputStream result = compiler.compile(source, dependencies);
       LOGGER.info("Compiled " + source.getPath() + ".");
       return result;
    }

}

We then need to add a bit of configuration to our Tapestry module class to register the transformer with Tapestry:

public void contributeStreamableResourceSource(
       MappedConfiguration<String, ResourceTransformer> configuration,
       @Inject @Symbol(SymbolConstants.PRODUCTION_MODE) final boolean productionMode) {
    final ResourceTransformer compassTransformer = new CompassResourceTransformer(productionMode);
    configuration.add("sass", compassTransformer);
    configuration.add("scss", compassTransformer);
}

public void contributeContentTypeAnalyzer(MappedConfiguration<String, String> configuration) {
    configuration.add("sass", "text/css");
    configuration.add("scss", "text/css");
}

(We opted to create a component library from the Compass support, but the quickest way to get going is to add this to your application module class.)

Now, all that is missing is the implementation of CompassCompiler. This turns out to be more delecate than it first seems, because we have to consider and work around a few things:

Because of all these complications, CompassCompiler is a rather verbose monster of a class. It would surely be possible to refactor this into something better, but I just stopped when things were first working.

public class CompassCompiler {

    private static final Pattern IMPORT_PATTERN = Pattern.compile(
       "@import\\s+[\"\'](\\S+)[\"\']\\s*;");
    private static final Pattern IMAGE_PATTERN = Pattern.compile(
       "(?:image-url|image-width|image-height)\\s*\\(\\s*[\"\'](\\S+)[\"\']\\s*.*?\\)");

    private final boolean productionMode;
    private final File workDir;
    private final File targetDir;
    {
       try {
         workDir = createEmptyTempDir("compass", null);
         targetDir = new File(workDir, "css");
         targetDir.mkdir();
       } catch (IOException e) {
         throw new RuntimeException(e);
       }
    }
    private final EmbedEvalUnit script;

    public CompassCompiler(boolean productionMode) {
       this.productionMode = productionMode;
       this.script = this.createScript();
    }

    public synchronized InputStream compile(final Resource source, final ResourceDependencies resourceDependencies) throws IOException {
       this.addConfigFile();
       final Set<Resource> dependencies = this.findDependencies(resourceDependencies, source);

       final Map<Resource, Set<Resource>> images = new HashMap<Resource, Set<Resource>>();
       images.putAll(this.findImageDependencies(source));
       for (final Resource dependency: dependencies) {
         images.putAll(this.findImageDependencies(dependency));
       }

       this.copyResourceWithDependencies(source, dependencies);
       this.copyImages(images);

       this.transformDir();

       final File resultFile = this.findResultFile(source);
       this.deleteInputFile(source);
       return new FileInputStream(resultFile);
    }

    private Set<Resource> findDependencies(final ResourceDependencies resourceDependencies, final Resource resource) throws IOException {
       final String fileContent = asString(resource);
       final Collection<String> importsInFile = this.findImportsInFile(fileContent);

       final Set<Resource> result = new HashSet<Resource>(); 
       for (final String importName: importsInFile) {
         final Collection<String> possibleFileNames = possibleFileNames(importName);
         final Resource dependency = this.findResourceWithName(resource, possibleFileNames);
         if (dependency != null) {
          resourceDependencies.addDependency(dependency);
          result.add(dependency);
          result.addAll(findDependencies(resourceDependencies, dependency));
         }
       }
       return result;
    }

    private Collection<String> findImportsInFile(String file) {
       final Set<String> imports = new HashSet<String>();
       final Matcher matcher = IMPORT_PATTERN.matcher(file);
       while (matcher.find()) {
         imports.add(matcher.group(1));
       }
       return imports;
    }

    private  Map<Resource, Set<Resource>> findImageDependencies(final Resource resource) throws IOException {
       final String fileContent = asString(resource);
       final Collection<String> imagesInFile = this.findImagesInFile(fileContent);

       final Set<Resource> result = new HashSet<Resource>(); 
       for (final String imageName: imagesInFile) {
         final Resource image= this.findResourceWithName(resource, Collections.singleton(imageName));
         if (image != null) {
          result.add(image);
         }
       }
       return Collections.singletonMap(resource, result);
    }

    private Collection<String> findImagesInFile(String file) {
       final Set<String> imports = new HashSet<String>();
       final Matcher matcher = IMAGE_PATTERN.matcher(file);
       while (matcher.find()) {
         imports.add(matcher.group(1));
       }
       return imports;
    }

    private static Collection<String> possibleFileNames(String importName) {
       final String folder = getFolder(importName);
       final String file =  getFileName(importName);

       return Arrays.asList(folder + file + ".scss", folder + file + ".sass", 
          folder + "_" + file + ".scss", folder + "_" + file + ".sass");
    }

    private static String getFileName(String path) {
       return path.substring(startIdxOfFileName(path));
    }

    private static String getFolder(String path) {
       return path.substring(0, startIdxOfFileName(path));
    }

    private static String createResourcePath(Resource resource) {
       if (resource instanceof ContextResource) {
         return "context/" + resource.getPath();
       } else if (resource instanceof ClasspathResource) {
         return "classpath/" + resource.getPath();
       } else {
         return resource.getPath();
       }
    }

    private String createResultPath(Resource resource) {
       final String resourcePath = createResourcePath(resource);

       final int extensionStartIdx = resourcePath.lastIndexOf(".");
       final String resourceWithoutExtension = resourcePath.substring(0, extensionStartIdx);

       return resourceWithoutExtension + ".css";
    }

    private static int startIdxOfFileName(String path) {
       if (path.contains("/")) {
         return path.lastIndexOf("/") + 1;
       } else {
         return 0;
       }
    }

    private Resource findResourceWithName(Resource relativeTo, Collection<String> possibleNames) {
       for (final String fileName : possibleNames) {
         final Resource possibleResource = relativeTo.forFile(fileName);
         if (possibleResource.exists()) {
          return possibleResource;
         }
       }
       return null;
    }

    private void copyResourceWithDependencies(final Resource resource, final Set<Resource> dependencies) throws IOException {
       final Set<Resource> resourcesToCopy = new HashSet<Resource>();
       resourcesToCopy.add(resource);
       resourcesToCopy.addAll(dependencies);

       for (final Resource resourceToCopy : resourcesToCopy) {
         final String filePath = createResourcePath(resourceToCopy);
         this.copyToPath(resourceToCopy, filePath);
       }
    }

    private void copyImages(final Map<Resource, Set<Resource>> resourceToImages) throws IOException {
       for (final Entry<Resource, Set<Resource>> entry: resourceToImages.entrySet()) {
         final Resource relativeTo = entry.getKey();
         final Set<Resource> images = entry.getValue();
         for(Resource image : images) {
          final String filePath = createImagePath(image, relativeTo);
          this.copyToPath(image, filePath);
         }
       }
    }

    private static String createImagePath(final Resource image, final Resource relativeTo) {
       final String relativeToFolder = relativeTo.getFolder();
       final String imagePath = image.getPath();
       if (imagePath.startsWith(relativeToFolder)) {
         final String relativePath = imagePath.substring(relativeToFolder.length());
         return "images/" + relativePath;
       } else {
         throw new RuntimeException("Only images on the same or on a deeper level as the referencing SASS file are supported.");
       }
    }

    private void copyToPath(final Resource resourceToCopy, final String filePath) throws IOException {
       final File copiedFile = new File(workDir, filePath);
       if (copiedFile.exists()) {
         copiedFile.delete();
       } else {
         copiedFile.getParentFile().mkdirs();
       }
       copiedFile.createNewFile();

       InputStream in = resourceToCopy.openStream();
       OutputStream out = null;
       try {
         out = new FileOutputStream(copiedFile);
         IOUtils.copy(in, out);
       } finally {
         IOUtils.closeQuietly(in);
         IOUtils.closeQuietly(out);
       }
    }

    private void addConfigFile() throws IOException {
       final InputStream configFileIn = this.getClass().getResourceAsStream("/config.rb");
       final File configFile = new File(this.workDir, "config.rb");
       if (!configFile.exists()) {
         configFile.createNewFile();

         OutputStream out = null;
         try {
          out = new FileOutputStream(configFile);
          IOUtils.copy(configFileIn, out);
         } finally {
          IOUtils.closeQuietly(configFileIn);
          IOUtils.closeQuietly(out);
         }
       }
    }

    private void transformDir() {
       this.script.run();
    }

    private EmbedEvalUnit createScript() {
       final StringBuilder script = new StringBuilder();
       script.append("require 'rubygems' \n");
       script.append("require 'sass' \n");
       script.append("require 'sass/plugin' \n");
       script.append("require 'compass' \n");
       script.append("require 'susy' \n");
       script.append("require 'normalize' \n");
       script.append("require 'compass/commands' \n");

       script.append("command = Compass::Commands::UpdateProject.new(");
       script.append("     '" + workDir.getAbsolutePath() + "',");
       final String environment = this.productionMode? ":production" : ":development";
       script.append("{ 'environment' => " + environment + "}");
       script.append(") \n");

       script.append("command.perform");

       RubyInstanceConfig.FASTEST_COMPILE_ENABLED = true;
       RubyInstanceConfig.FASTOPS_COMPILE_ENABLED = true;
       RubyInstanceConfig.FASTSEND_COMPILE_ENABLED = true;
       RubyInstanceConfig.INLINE_DYNCALL_ENABLED = true;

       final ScriptingContainer scriptingContainer = new ScriptingContainer(LocalContextScope.SINGLETHREAD);
       scriptingContainer.setCompileMode(CompileMode.FORCE);
       return scriptingContainer.parse(script.toString());
    }

    private File findResultFile(final Resource resource) throws IOException {
       final String pathOfResultFile = createResultPath(resource);
       final File resultFile = new File(this.targetDir, pathOfResultFile);
       if (resultFile.exists()) {
         return resultFile;
       } else {
         throw new IOException("Could not find target CSS in " + targetDir + ".");
       }
    }

    private void deleteInputFile(Resource resource) {
       final String path = createResourcePath(resource);
       final File inputFileToDelete = new File(this.workDir, path);
       inputFileToDelete.delete();
    }

    private static File createEmptyTempDir(final String prefix, final String suffix) throws IOException {
       final File dir = File.createTempFile(prefix, suffix);
       if (!dir.delete() || !dir.mkdir()) {
         throw new IOException("Failure creating directory.");
       }
       return dir;
    }

    private static String asString(Resource resource) throws IOException {
       InputStream in = null;
       try {
         in = resource.openStream();
         return IOUtils.toString(in, "UTF-8");
       } finally {
         IOUtils.closeQuietly(in);
       }
    }

}

Notes about the code:

Given all the monkeypatching, I don't know if it makes sense to make an effort to refactor and open source this as a library at some point. The upside for us is that we eliminated all external scripts from our build process entirely, and we can deploy SASS files with the production code.

Summary

Good:

Bad:

Comments