Monday, August 24, 2009

NLess is More

I’ve been brushing up on my CSS skills and this weekend I ran across an interesting Ruby on Rails project called Less.  This gem takes CSS-like code to let you do some pretty nifty tricks.  For example, you can use variables so that things like colors are defined in a single place, then re-used throughout your design:

LESS CSS
@brand_color: #4D926F;

#header {
  color: @brand_color;
}

h2 {
  color: @brand_color;
}
#header {
  color: #4D926F;
}

h2 {
  color: #4D926F;
}

You can even add, subtract, multiply and divide property values to do things like lighten and darken colors or express proportions.

LESS CSS
@base-color: #111;

#footer { 
  color: @base-color + #111; 
}
#footer { 
  color: #222; 
}

I thought this was a pretty cool idea, so in the long tradition of .NET developers stealing good ideas and prefixing them with the letter N, I put together a proof-of-concept called NLess to do similar things in .NET/Visual Studio using T4.  So far it only does a subset of what LESS does (you might say it does even less than LESS), specifically variable substitution and color addition and subtraction.  However, it also combines all CSS in the template directory and then minifies it, both of which are good practices for speeding up page rendering.  It never changes your existing CSS, instead placing everything in a single file called ‘nless.css’, which you can then use in your pages.  In my non-scientific test, 7k worth of CSS was reduced to 3k by removing whitespace and comments.  I tried to keep it more or less similar to LESS in syntax, with the one change so far that variable definitions are kept in comments (ie /*@baseColor:#1111*/)

Here is the T4 listing.  If you want to play with it, just copy this into a file named NLess.tt in the same folder as one or more .css files.

<#@ template language="C#v3.5" hostspecific="True" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Drawing" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Text.RegularExpressions" #>
<#@ import namespace="System.Globalization" #>
<#@ import namespace="System.Drawing" #>
<#@ output extension=".css" #> 
<#
    var files = "*"; //TODO: Also support listing files comma-separated
    var baseDir = Host.ResolvePath(".");
    var templateFile = Host.TemplateFile.Replace(".tt", ".css");
    var processor = new NLessProcessor();
    var    output = processor.Combine(baseDir, files, templateFile);
    output = processor.ReplaceVariables(output);
    output = processor.ApplyColorAddition(output);
    output = processor.ApplyColorSubtraction(output);
    output = processor.Minify(output);
 
#>
<#= output#>
 
<#+
 
 public class NLessProcessor
    {
        public string ApplyColorAddition(string css)
        {
            var result = css;
            var colorAdditions = Regex.Matches(css, @"#[0-9A-Fa-f]{3,6}\s{0,2}\+\s{0,2}#[0-9A-Fa-f]{3,6}");
            foreach (Match colorAddition in colorAdditions)
            {
                var parts = colorAddition.Value.Trim().Split('+');
                var addedColor = AddColor(parts[0], parts[1]);
                result = result.Replace(colorAddition.Value, addedColor);
            }
            return result;
        }
 
        public string ApplyColorSubtraction(string css)
        {
            var result = css;
            var colorAdditions = Regex.Matches(css, @"#[0-9A-Fa-f]{3,6}\s{0,2}-\s{0,2}#[0-9A-Fa-f]{3,6}");
            foreach (Match colorAddition in colorAdditions)
            {
                var parts = colorAddition.Value.Trim().Split('-');
                var addedColor = SubtractColor(parts[0], parts[1]);
                result = result.Replace(colorAddition.Value, addedColor);
            }
            return result;
        }
 
        public string Combine(string baseDir, string fileList, string templateFile)
        {
            var result = new StringBuilder();
            var files = GetFiles(baseDir, fileList, templateFile);
 
            foreach (var file in files)
            {
                using (var reader = file.OpenText())
                {
                    result.Append(reader.ReadToEnd());
                    result.AppendLine();
                }
            }
            return result.ToString();
        }
 
        public IEnumerable<FileInfo> GetFiles(string baseDir, string fileList, string templateFile)
        {
            if (fileList == "*")
            {
                var dir = new DirectoryInfo(baseDir);
                var files = dir.GetFiles("*.css");
                var result = from f in files
                             where f.FullName != templateFile
                             select f;
                return result;
            }
            return new List<FileInfo>();
        }
 
        public string Minify(string css)
        {
            var result = RemoveComments(css);
            result = RemoveWhitespace(result);
            return result;
        }
 
        public string ReplaceVariables(string css)
        {
            //TODO: Compiled regex
            var result = css;
            var tokenMatch = Regex.Match(css, "@.+:.+;");
            while (tokenMatch != null && tokenMatch.Success)
            {
 
                result = result.Replace(tokenMatch.Value, "");
                var parts = tokenMatch.Value.Split(':');
                var key = parts[0];
                var value = String.Join("", parts, 1, parts.Length - 1)
                    .Replace(";", "");
 
                result = result.Replace(key, value);
                tokenMatch = Regex.Match(result, "@.+:.+;");
 
            }
            return result;
        }
 
        private string AddColor(string color1, string color2)
        {
            var rgbColor1 = ColorTranslator.FromHtml(color1);
            var rgbColor2 = ColorTranslator.FromHtml(color2);
            var result = Color.FromArgb(Math.Min(255, rgbColor1.A + rgbColor2.A), Math.Min(255, rgbColor1.R + rgbColor2.R), Math.Min(255, rgbColor1.G + rgbColor2.G), Math.Min(255, rgbColor1.B + rgbColor2.B));
            return ColorTranslator.ToHtml(result);
        }
 
        private string RemoveComments(string css)
        {
            return Regex.Replace(css, @"/\*.+?\*/", "", RegexOptions.Singleline);
        }
 
        private string RemoveWhitespace(string css)
        {
            var result = Regex.Replace(css, @"\s{2,}", "");
            result = Regex.Replace(result, @"\{\s*", "{");
            result = Regex.Replace(result, @"\s*\}", "}");
            result = Regex.Replace(result, @"^\s*", "");
            result = Regex.Replace(result, @":\s*", ":");
            result = Regex.Replace(result, @",\s*", ",");
            result = Regex.Replace(result, @"\s*,", ",");
            return result;
        }
 
        private string SubtractColor(string color1, string color2)
        {
            var rgbColor1 = ColorTranslator.FromHtml(color1);
            var rgbColor2 = ColorTranslator.FromHtml(color2);
 
            var result = Color.FromArgb(Math.Max(0, rgbColor1.A - rgbColor2.A), Math.Max(0, rgbColor1.R - rgbColor2.R), Math.Max(0, rgbColor1.G - rgbColor2.G), Math.Max(0, rgbColor1.B - rgbColor2.B));
            return ColorTranslator.ToHtml(result);
        }
    }
 
#>

Once copied, all you have to do is run templates and nless.css should appear ‘beneath’ it. I even went TDD on this and (after an initial ‘spike’) started with tests for most of the functionality:

using System.Linq;
using System.Text;
using NUnit.Framework;
 
namespace NLess.Tests
{
    [TestFixture]
    public class NLessProcessorTests
    {
        [Test]
        public void ReplaceVariables_should_replace_variables_with_instance_value()
        {
            var testCss = @"/* @baseColor: #555555; */ 
                            .testCase{ color:@baseColor; }";
 
            var target = new NLessProcessor();
            var actual = target.ReplaceVariables(testCss);
            Assert.AreEqual(@"/*  */ 
                            .testCase{ color: #555555; }", actual);
        }
 
        [Test]
        public void ReplaceVariables_should_replace_multiple_variables()
        {
            var testCss = @"/*@baseColor: #5c87b2;
                            @darkerBaseColor: @baseColor + #222222;
                            */
                            .testCase{ color: @darkerBaseColor; }";
            var target = new NLessProcessor();
            var actual = target.ReplaceVariables(testCss);
            actual = target.Minify(actual);
            Assert.AreEqual(@".testCase{color:#5c87b2 + #222222;}", actual);
 
        }
 
        [Test]
        public void Minify_should_remove_single_line_slashStar_comments()
        {
            var testCss = @"/*  some comment */ .class{ color:black;}";
            var target = new NLessProcessor();
            var actual = target.Minify(testCss);
            Assert.AreEqual(@".class{color:black;}", actual);
        }
 
        [Test]
        public void Minify_should_remove_multi_line_slashstar_comments()
        {
            var testCss = @"/*  some comment 
                                that takes multiple lines*/ 
                            .class{ color:black;}";
            var target = new NLessProcessor();
            var actual = target.Minify(testCss);
            Assert.AreEqual(@".class{color:black;}", actual);
        }
 
        [Test]
        public void Minify_should_remove_newlines()
        {
            var testCss = @".class{color:black;}
                            #tagId{color:blue;}";
            var target = new NLessProcessor();
            var actual = target.Minify(testCss);
            Assert.AreEqual(@".class{color:black;}#tagId{color:blue;}", actual);
        }
 
        [Test]
        public void Minify_should_remove_unneeded_spaces()
        {
            var testCss = @".class{ font-family:Verdana , Sans-Serif; }
                            #tagId{ color: blue; }";
            var target = new NLessProcessor();
            var actual = target.Minify(testCss);
            Assert.AreEqual(@".class{font-family:Verdana,Sans-Serif;}#tagId{color:blue;}", actual);
        }
 
        [Test]
        public void Minify_should_leave_needed_spaces()
        {
            var testCss = @".class{ margin:10px 8px 9px 7px; }";                           
            var target = new NLessProcessor();
            var actual = target.Minify(testCss);
            Assert.AreEqual(@".class{margin:10px 8px 9px 7px;}", actual);
        }
 
        [Test]
        public void ApplyColorAddition_should_add_colors()
        {
            var testCss = @".class{color:#222222 + #111111;}";
            var target = new NLessProcessor();
            var actual = target.ApplyColorAddition(testCss);
            Assert.AreEqual(@".class{color:#333333;}", actual);
        }
 
        [Test]
        public void ApplyColorSubtraction_should_subtract_colors()
        {
            var testCss = @".class{color:#222222 - #111111;}";
            var target = new NLessProcessor();
            var actual = target.ApplyColorSubtraction(testCss);
            Assert.AreEqual(@".class{color:#111111;}", actual);
        }
 
        [Test]
        public void ApplyColorAddition_should_add_rgb_individually()
        {
            var testCss = @"/*@baseColor: #EEEEEE;
                            @darkerBaseColor: @baseColor + #000022;
                            */
                            .testCase{ color: @darkerBaseColor; }";
            var target = new NLessProcessor();
            var actual = target.ReplaceVariables(testCss);
            actual = target.Minify(actual);
            actual = target.ApplyColorAddition(actual);
            Assert.AreEqual(@".testCase{color:#EEEEFF;}", actual);
        }
 
        [Test]
        public void ApplyColorSubtraction_should_subtract_rgb_individually()
        {
            var testCss = @"/*@baseColor: #111111;
                            @darkerBaseColor: @baseColor - #000022;
                            */
                            .testCase{ color: @darkerBaseColor; }";
            var target = new NLessProcessor();
            var actual = target.ReplaceVariables(testCss);
            actual = target.Minify(actual);
            actual = target.ApplyColorSubtraction(actual);
            Assert.AreEqual(@".testCase{color:#111100;}", actual);
        }
        
    }
}
Note I’m just testing against a copy of ‘NLessProcessor’, which gets manually cut-and-pasted into the t4 once the tests pass. As an aside, this caught several bugs early on – I’d definitely recommend using TDD for code generation like this.

  If nothing else, it’s been a good T4 and CSS exercise.  I think to really do this right, I’d need to implement a true parser and compiler – currently I’m just using Regex to munch the css as strings.  It works, but is not very elegant.  I’d also like to finish out the LESS features – other operations, nested CSS, and mixins.  I also have a few ideas for additional functions, like ComplementaryOf(#ff0000) or VerticalGradient(#ff0000, #00ffff), which would be nice to have when coming up with designs.  So, we’ll see where this goes- if you’re interested in the full source or like the idea and want to see more, let me know your ideas in the comments below.

kick it on DotNetKicks.com

No comments: