Friday, August 28, 2009

Even More NLess

I’ve found some time to add a bit to my NLess CSS Minify-and-more T4 template, which is based on ideas from the LESS Ruby gem.  This is still not feature-complete with the LESS gem, but it does add quite a few things from my previous script.  Namely, it allows addition, multiplication, subtraction and division of color, where the previous version just did addition and subtraction.  It also now allows multiplication and division of units, which lets you do stuff like this:

#rightSidebar
{
    width:@pageWidth / 5;
    float:right;    
}

It also supports what LESS calls mixins, so that you can reuse chunks of CSS throughout your app:

.panel
{
    background-color: @baseColor + #999999;
    padding:.5em;
    border:1px solid @darkerBaseColor;
}
 
#rightSidebar
{
   .panel;
    width:@pageWidth / 5;
    float:right;    
}
 
#leftSidebar
{
   .panel;
    width:@pageWidth / 5;
    float:left;    
}

This seems kind of silly at first, but it does have a nice benefit in that it leaves your markup clean and semantic:

<div id=”rightSidebar”>blah</div>

instead of

<div id=”rightSidebar” class=”panel”>blah</div>

As before, this is really just a little fun thing that may or may not make it into production.  I like the ideas, but am a little worried about all the Regex goo (now I have two problems :).  I’m pretty sure the right way to do this would be to implement a full-on DSL for processing a CSS-like language with these features, something I’m not sure I’m ready to tackle.  Just as important to me is trying out some TDD tactics in building this T4.  You don’t see testing mentioned much alongside code-generation, but it’s really been a good fit.  Writing tests first has really helped me think through exactly how I want the code-gen to work, and definitely caught some bugs in the process.

Anyway, here’s the updated t4.  As before, to use, simply copy this to a file named ‘NLess.tt’ in the same folder as your .css file(s).  The result will be a file named NLess.css that you can use in place of your CSS:

<#@ template language="C#v3.5" hostspecific="True" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Drawing" #>
<#@ assembly name="System.Web" #>
<#@ 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" #>
<#@ import namespace="System.Web.UI.WebControls" #>
<#@ 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.ApplyColorDivision(output);
    output = processor.ApplyColorMultiplication(output);
    output = processor.ApplyUnitsMultiplication(output);
    output = processor.ApplyUnitsDivision(output);
    output = processor.ApplyMixins(output);
    output = processor.Minify(output);
 
#>
<#= output#>
 
<#+
 
 public class NLessProcessor
    {
        public string ApplyColorAddition(string css)
        {
            var result = css;
            var expressions = Regex.Matches(css, @"#[0-9A-Fa-f]{3,6}\s{0,2}\+\s{0,2}#[0-9A-Fa-f]{3,6}");
            foreach (Match expression in expressions)
            {
                var parts = expression.Value.Trim().Split('+');
                var addedColor = AddColor(parts[0], parts[1]);
                result = result.Replace(expression.Value, addedColor);
            }
            return result;
        }
 
        public string ApplyColorSubtraction(string css)
        {
            var result = css;
            var expressions = Regex.Matches(css, @"#[0-9A-Fa-f]{3,6}\s{0,2}-\s{0,2}#[0-9A-Fa-f]{3,6}");
            foreach (Match expression in expressions)
            {
                var parts = expression.Value.Trim().Split('-');
                var addedColor = SubtractColor(parts[0], parts[1]);
                result = result.Replace(expression.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, @"@[\w\s]+:[^;]+;");
            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, @"@[\w\s]+:[^;]+;");
 
            }
            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*", ",");
            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);
        }
 
        public string ApplyColorMultiplication(string css)
        {
            var result = css;
            var expressions = Regex.Matches(css, @"#[0-9A-Fa-f]{3,6}\s{0,2}\*\s{0,2}#[0-9A-Fa-f]{3,6}");
            foreach (Match expression in expressions)
            {
                var parts = expression.Value.Trim().Split('*');
                var color = MultiplyColor(parts[0], parts[1]);
                result = result.Replace(expression.Value, color);
            }
            return result;
        }
 
        private string MultiplyColor(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);
        }
 
        public string ApplyColorDivision(string css)
        {
            var result = css;
            var expressions = Regex.Matches(css, @"#[0-9A-Fa-f]{3,6}\s{0,2}/\s{0,2}#[0-9A-Fa-f]{3,6}");
            foreach (Match expression in expressions)
            {
                var parts = expression.Value.Trim().Split('/');
                var color = DivideColor(parts[0], parts[1]);
                result = result.Replace(expression.Value, color);
            }
            return result;
        }
 
        private string DivideColor(string color1, string color2)
        {
            var rgbColor1 = ColorTranslator.FromHtml(color1);
            var rgbColor2 = ColorTranslator.FromHtml(color2);
            var result = Color.FromArgb(rgbColor1.A / rgbColor2.A, rgbColor1.R / rgbColor2.R, rgbColor1.G / rgbColor2.G, rgbColor1.B / rgbColor2.B);
            return ColorTranslator.ToHtml(result);
        }
 
        public string ApplyUnitsDivision(string css)
        {
            var result = css;
            var expressions = new List<Match>();
            expressions.AddRange(Regex.Matches(css, @"[0-9]+px\s{0,2}/\s{0,2}[0-9]+").Cast<Match>());
            expressions.AddRange(Regex.Matches(css, @"[0-9]+em\s{0,2}/\s{0,2}[0-9]+").Cast<Match>());
            expressions.AddRange(Regex.Matches(css, @"[0-9]+pt\s{0,2}/\s{0,2}[0-9]+").Cast<Match>());
            expressions.AddRange(Regex.Matches(css, @"[0-9]+%\s{0,2}/\s{0,2}[0-9]+").Cast<Match>());
            //TODO: Support other unit types?
            foreach (var expression in expressions)
            {
                var parts = expression.Value.Trim().Split('/');
                var units = DivideUnits(parts[0], parts[1]);
                result = result.Replace(expression.Value, units);
            }
            return result;
        }
 
        private string DivideUnits(string unitsToBeDivided, string divideBy)
        {
            var nativeUnitToBeDivided = Unit.Parse(unitsToBeDivided, CultureInfo.InvariantCulture);
            var intDivideBy = int.Parse(divideBy, CultureInfo.InvariantCulture);
            var result = new Unit(nativeUnitToBeDivided.Value / intDivideBy, nativeUnitToBeDivided.Type);
            return result.ToString();
        }
 
        public string ApplyUnitsMultiplication(string css)
        {
            var result = css;
            var expressions = new List<Match>();
            expressions.AddRange(Regex.Matches(css, @"[0-9]+px\s{0,2}\*\s{0,2}[0-9]+").Cast<Match>());
            expressions.AddRange(Regex.Matches(css, @"[0-9]+em\s{0,2}\*\s{0,2}[0-9]+").Cast<Match>());
            expressions.AddRange(Regex.Matches(css, @"[0-9]+pt\s{0,2}\*\s{0,2}[0-9]+").Cast<Match>());
            expressions.AddRange(Regex.Matches(css, @"[0-9]+%\s{0,2}\*\s{0,2}[0-9]+").Cast<Match>());
            //TODO: Support other unit types?
            foreach (var expression in expressions)
            {
                var parts = expression.Value.Trim().Split('*');
                var units = MultiplyUnits(parts[0], parts[1]);
                result = result.Replace(expression.Value, units);
            }
            return result;
        }
 
        private string MultiplyUnits(string unitsToBeMultiplied, string multiplyBy)
        {
            var nativeUnitToBeMultiplied = Unit.Parse(unitsToBeMultiplied, CultureInfo.InvariantCulture);
            var intMultiplyBy = int.Parse(multiplyBy, CultureInfo.InvariantCulture);
            var result = new Unit(nativeUnitToBeMultiplied.Value * intMultiplyBy, nativeUnitToBeMultiplied.Type);
            return result.ToString();
        }
 
        public string ApplyMixins(string css)
        {
            var result = css;
            var blocks = GetMixinBlocks(css);
            foreach (var block in blocks)
            {
                result = result.Replace(block.Selector + ";", block.Style);
            }
            return result;
        }
        private class MixinBlock
        {
            public string Selector { get; set; }
            public string Style { get; set; }
        }
        private IEnumerable<MixinBlock> GetMixinBlocks(string css)
        {
            var blocks = Regex.Matches(css, @"\.{1}[\w\s]+\{{1}[^}]+\}{1}");
            foreach (Match block in blocks)
            {
                var parts = block.Value.Split('{');
                var result = new MixinBlock
                                 {
                                     Selector = parts[0].Trim(),
                                     Style = parts[1].Trim()
                                 };
                result.Style = result.Style.Substring(0, result.Style.Length - 1);
                yield return result;
            }
        }
    }
 
 
#>

And of course my tests:

namespace NLess.Tests
{
    [TestFixture]
    public class NLessProcessorTests
    {
        [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 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 ApplyColorDivision_should_divide_colors()
        {
            var testCss = @".class{color:#222222 / #020202;}";
            var target = new NLessProcessor();
            var actual = target.ApplyColorDivision(testCss);
            Assert.AreEqual(@".class{color:#111111;}", actual);
        }
 
        [Test]
        public void ApplyColorDivision_should_divide_rgb_individually()
        {
            var testCss = @".class{color:#222222 / #010102;}";
            var target = new NLessProcessor();
            var actual = target.ReplaceVariables(testCss);
            actual = target.Minify(actual);
            actual = target.ApplyColorDivision(actual);
            Assert.AreEqual(@".class{color:#222211;}", actual);
        }
 
        [Test]
        public void ApplyColorMultiplication_should_multiply_colors()
        {
            var testCss = @".class{color:#222222 * #020202;}";
            var target = new NLessProcessor();
            var actual = target.ApplyColorMultiplication(testCss);
            Assert.AreEqual(@".class{color:#444444;}", actual);
        }
 
        [Test]
        public void ApplyColorMultiplication_should_multiply_rgb_individually()
        {
            var testCss = @".class{color:#222222 * #010102;}";
            var target = new NLessProcessor();
            var actual = target.ReplaceVariables(testCss);
            actual = target.Minify(actual);
            actual = target.ApplyColorMultiplication(actual);
            Assert.AreEqual(@".class{color:#222244;}", 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 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);
        }
 
        [Test]
        public void ApplyMixins_should_copy_class_style_into_context()
        {
            var testCss = @".rounded_corners{border-radius: 8px;}
                            #myElement{.rounded_corners; width:150px;}";
            var target = new NLessProcessor();
            var actual = target.ApplyMixins(testCss);
            Assert.AreEqual(@".rounded_corners{border-radius: 8px;}
                            #myElement{border-radius: 8px; width:150px;}", actual);
        }
 
        [Test]
        public void ApplyUnitsDivision_should_divide_ems()
        {
            var testCss = @".class{width:100em / 2;}";
            var target = new NLessProcessor();
            var actual = target.ApplyUnitsDivision(testCss);
            Assert.AreEqual(@".class{width:50em;}", actual);
        }
 
        [Test]
        public void ApplyUnitsDivision_should_divide_percents()
        {
            var testCss = @".class{width:100% / 2;}";
            var target = new NLessProcessor();
            var actual = target.ApplyUnitsDivision(testCss);
            Assert.AreEqual(@".class{width:50%;}", actual);
        }
 
        [Test]
        public void ApplyUnitsDivision_should_divide_pixels()
        {
            var testCss = @".class{width:100px / 2;}";
            var target = new NLessProcessor();
            var actual = target.ApplyUnitsDivision(testCss);
            Assert.AreEqual(@".class{width:50px;}", actual);
        }
 
        [Test]
        public void ApplyUnitsDivision_should_divide_pts()
        {
            var testCss = @".class{width:100pt / 2;}";
            var target = new NLessProcessor();
            var actual = target.ApplyUnitsDivision(testCss);
            Assert.AreEqual(@".class{width:50pt;}", actual);
        }
 
        [Test]
        public void ApplyUnitsMultiplication_should_multiply_ems()
        {
            var testCss = @".class{width:100em * 2;}";
            var target = new NLessProcessor();
            var actual = target.ApplyUnitsMultiplication(testCss);
            Assert.AreEqual(@".class{width:200em;}", actual);
        }
 
        [Test]
        public void ApplyUnitsMultiplication_should_multiply_percents()
        {
            var testCss = @".class{width:100% * 2;}";
            var target = new NLessProcessor();
            var actual = target.ApplyUnitsMultiplication(testCss);
            Assert.AreEqual(@".class{width:200%;}", actual);
        }
 
        [Test]
        public void ApplyUnitsMultiplication_should_multiply_pixels()
        {
            var testCss = @".class{width:100px * 2;}";
            var target = new NLessProcessor();
            var actual = target.ApplyUnitsMultiplication(testCss);
            Assert.AreEqual(@".class{width:200px;}", actual);
        }
 
        [Test]
        public void ApplyUnitsMultiplication_should_multiply_pts()
        {
            var testCss = @".class{width:100pt * 2;}";
            var target = new NLessProcessor();
            var actual = target.ApplyUnitsMultiplication(testCss);
            Assert.AreEqual(@".class{width:200pt;}", actual);
        }
 
        [Test]
        public void Minify_should_leave_needed_spaces_between_units()
        {
            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 Minify_should_leave_needed_spaces_in_border_definitions()
        {
            var testCss = @".class{ border:1px solid black; }";
            var target = new NLessProcessor();
            var actual = target.Minify(testCss);
            Assert.AreEqual(@".class{border:1px solid black;}", actual);
        }
 
        [Test]
        public void Minify_should_leave_needed_spaces_in_border_definitions_that_contain_variables()
        {
            var testCss = @"/*@color: black;*/
                            .class{ border:1px solid @color; }";
            var target = new NLessProcessor();
            var actual = target.ReplaceVariables(testCss);
            actual = target.Minify(actual);
            Assert.AreEqual(@".class{border:1px solid 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_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_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 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 ReplaceVariables_should_replace_variables_in_single_line()
        {
            var testCss = @"/*@color:black;*/ .class{ border:1px solid @color; }";
            var target = new NLessProcessor();
            var actual = target.ReplaceVariables(testCss);
            actual = target.Minify(actual);
            Assert.AreEqual(@".class{border:1px solid black;}", actual);
        }
 
        [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);
        }
    }
}