Large Scale Less Part 4 - Converting Less to CSS With MSBuild

by Matt Perdeck 13. April 2013 14:17

In part 3, we saw 4 options for building your LESS files into CSS files. It showed how to express these using DOS batch commands in post build events. In this part 4, we'll see how to do the same in MSBuild, and how to implement Clean and Publish.

Contents

Integrating LESS compilation in your MSBuild project

When you build your solution or project, Visual Studio invokes MSBuild to do all the steps required to build your project, such as compiling source files, copying files, etc. Those steps are coded as MSBuild definitions in the .csproj or .vbproj project file associated with your project. If you open that file for one of your projects in your favorite text editor, you'll find that it is simply a text file with the build definitions written in XML.

When you create a post build event, Visual Studio simply adds your batch commands to the .csproj or .vbproj file.

In part 3, you saw four options for building CSS files out of your LESS files using DOS batch commands. Here we'll see how to do the same using MSBuild definitions instead of batch commands. To make this not too long, we'll only do this for options 1 and 4, the two most interesting options.

The advantages of using MSBuild directly over using DOS batch commands in post build events are:

  • MSBuilds is much more powerful than DOS batch commands, especially when creating build definitions.
  • It allows you to ensure that when you clean your project, any compiled CSS files are removed along with compiled binaries. And that when you publish your site, the compiled CSS files are published as well.

Lets now see how to write options 1 and 4 as MSBuild defintions instead of DOS batch commands.

Option 1. Additional LESS file per CSS file, which imports the constituent LESS files

To express option 1 in MSBuild definitions and implement Clean and Publish for your compiled CSS files, you could add the following to to your .csproj or .vbproj project file. Edit the file in a text editor such as Notepad, and add the following to the end, just before the closing </Project> tag:

  <!-- +++++++++++++++++++++ lessc parameters ++++++++++++++++++++++++++++ -->

  <PropertyGroup>
    <LessParams Condition=" '$(Configuration)' == 'Debug' ">--line-numbers=mediaquery --relative-urls</LessParams>
    <LessParams Condition=" '$(Configuration)' == 'Release' ">--yui-compress --relative-urls</LessParams>
  </PropertyGroup>

  <!-- ++++++++++++++++++++++ Additional LESS files to compile +++++++++++++++++++++++++++ -->

  <ItemGroup>
    <AdditionalFile Include="Content\CombinedMain.less">
      <InProject>false</InProject>
    </AdditionalFile>
    <AdditionalFile Include="Content\CombinedAdmin.less">
      <InProject>false</InProject>
    </AdditionalFile>
  </ItemGroup>

  <!-- ++++++++++++++++++++++++ Build +++++++++++++++++++++++++ -->

  <Target Name="CompileLess" 
          Inputs="**\*.less" 
          Outputs="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" 
          AfterTargets="Build">
    <Exec Command="lessc &quot;%(AdditionalFile.Identity)&quot; &quot;@(AdditionalFile->'%(RelativeDir)%(Filename).css')&quot; $(LessParams)" />
	
    <ItemGroup>
      <None Include="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
    <ItemGroup>
      <Content Include="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" />
      <Content Remove="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
  </Target>

  <!-- ++++++++++++++++++++++++ Clean +++++++++++++++++++++++++ -->

  <Target Name="CleanGeneratedCss" AfterTargets="Clean">
    <Delete Files="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" />
  </Target>

Lets hack this into manageable pieces and see how each piece works.

Using property elements to set LESS compiler options

  <PropertyGroup>
    <LessParams Condition=" '$(Configuration)' == 'Debug' ">--line-numbers=mediaquery --relative-urls</LessParams>
    <LessParams Condition=" '$(Configuration)' == 'Release' ">--yui-compress --relative-urls</LessParams>
  </PropertyGroup>

The first step is to work out the options to use with the LESS compiler, depending on the build configuration, and to store the chosen options somewhere for later use.

Whereas in C# or Visual Basic you'd store values in constants or variables, MSBuild uses properties to store scalar values. Property definitions live in property groups as you see here. If you define the same property twice, the last definition wins.

Here, the property LessParams has been created twice, for the Debug build configuration and then for the Release configuration. This works, because property definitions and almost all other MSBuild definitions can take a Condition attribute. If there is a condition, than only if it is true will the definition have any effect. That means that if the Configuration is Debug, the Release definition has no effect and so doesn't replace the Debug definition.

In addition to creating properties with a property group, they can also be passed to MSBuild via its property option. Visual Studio uses this to pass in the Configuration property, which is simply the configuration you set in the Configuration Manager in Visual Studio (in the Build menu). As shown above, to get the value of a property, it needs to be placed between $( and ).

Using an item group to define what LESS files to compile

Option 1 involves creating additional LESS files for each CSS file we want to generate. We can give MSBuild a list of these files using an item group:

  <ItemGroup>
    <AdditionalFile Include="Content\CombinedMain.less">
      <InProject>false</InProject>
    </AdditionalFile>
    <AdditionalFile Include="Content\CombinedAdmin.less">
      <InProject>false</InProject>
    </AdditionalFile>
  </ItemGroup>

Item groups let you define groups of things, most commonly files. Here an item group AdditionalFile is created to define all LESS files that need to be compiled.

This item group assumes that there are two LESS files that need to be compiled: CombinedMain.less and CombinedAdmin.less. And that they both sit in directory Content. You'll probably need to modify this for your own project.

What about the InProject element? Each item can have child elements with meta data. You'd use this to store additional information for each item. You can use any meta data names you want, but it would be good if your meta data names wouldn't clash with those of the built in well known meta data items - items provided by MSBuild for every item. In a moment we'll see how to access this meta data.

So why have the InProject meta data? Item groups can live within a target (which we'll come across later) or stand alone (as shown above). An issue with stand alone item groups is that Visual Studio thinks that they all represent files in the project - that is, any item you define will show up in Visual Studio's solution explorer, even if the file doesn't exist. If it is defined twice in the project, it will show up twice in solution explorer! Which can be annoying. To tell Visual Studio not do that, you add the meta data InProject to your items and set it to false, as was done here.

An alternative approach is to take the existing files in the project and create an item group by filtering out the ones you want. You'll see this in action later on.

Using a target to actually compile the LESS files

In MSBuild, when you want something done, you define a target, like so:

   <Target Name="CompileLess" 
          Inputs="**\*.less" 
          Outputs="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" 
          AfterTargets="Build">
    <Exec Command="lessc &quot;%(AdditionalFile.Identity)&quot; &quot;@(AdditionalFile->'%(RelativeDir)%(Filename).css')&quot; $(LessParams)" />
	
    <ItemGroup>
      <None Include="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
    <ItemGroup>
      <Content Include="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" />
      <Content Remove="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
  </Target>

In MSBuild, a target is set of tasks, such as compiling a file, copying files, etc. It's analogous to a method in C# or Visual Basic. You can create your own tasks as .Net classes, or use one of the many tasks provided out of the box.

The code above creates a target CompileLess that runs a single task, Exec. This task allows you to run a program that you'd normally run from the command line - in this case lessc, the LESS compiler.

There are a few more things here that probably require some explaining:

Target Build Order

  <Target Name="CompileLess" Inputs="**\*.less" Outputs="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" 
          AfterTargets="Build">

In MSBuild, there are several ways to execute a target:

  • Via the target parameter when you or Visual Studio run MSBuild.
  • By calling the target from another target, using tasks such as CallTarget and MSBuild.
  • By telling the target to run before or after another target, using the target attributes BeforeTargets, AfterTargets or DependsOnTargets. Interestingly, this way you don't have to modify the &quot;calling&quot; target.
  • By listing it in the InitialTargets or DefaultTargets attributes of the Project element.

Note that a target is only executed once during a build, no matter how often it is called.

By giving our CompileLess target the attribute AfterTargets="Build", it runs after a target named Build has run. Visual Studio runs the Build target when you do a build. This means that the CompileLess target runs each time you do a build.

Wildcards

  <Target Name="CompileLess" Inputs="**\*.less" Outputs="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" AfterTargets="Build">

MSBuild allows you to use wildcards to specify collections of files:

  • ? matches a single character
  • * matches zero or more characters, except for the characters that separate directories - / and \
  • ** matches a partial path

This means that **\*.less matches all LESS files in the project - ** matches all paths and *.less matches all LESS files.

Incremental Builds

  <Target Name="CompileLess" 
         Inputs="**\*.less" 
         Outputs="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" AfterTargets="Build">

Suppose you haven't changed your LESS files after the last build. In that case, there is no need to run the LESS compiler again, because the generated CSS files are still up to date. MSBuild supports this with incremental builds.

To make this work, you specify a set of input files and a set of output files. If all the output files are younger than all the input files (that is, none of the input files was changed after the output files were last generated), MSBuild will skip the target.

Here, Inputs is set to a wildcard matching all LESS files in the project. Outputs is set to all compiled CSS files, as well see now.

Transforms

  <Target Name="CompileLess" Inputs="**\*.less" Outputs="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" AfterTargets="Build">

Earlier on, we defined an itemgroup AdditionalFile with all the LESS files to be compiled. We're now after all CSS files that they will be compiled into. The relationship between each LESS file and its corresponding CSS file is simple: use the same directory and file name, but use extension .css instead of .less.

In MSBuild, we can do these transformation using the aptly named Transform feature. This takes the form:

@(item group->'one or more meta data and fixed text')  

In this case, all LESS file paths in the itemgroup AdditionalFile are transformed to their corresponding CSS file paths.

Well-known Item Metadata

  <Target Name="CompileLess" Inputs="**\*.less" Outputs="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" AfterTargets="Build">

During the introduction to item groups, we saw how each item can have meta data. In addition to the meta data you define yourself, you can use the meta data that is provided by MSBuild for all items, the well known item metadata.

Here you see two of the many available meta data:

  •  RelativeDir, which returns the directory of the item.
  • Filename, which returns the  file name, without directory or extension.

To get the value of a metadata (whether your own or well known), you surround it with %( and ).

Task Batching

<Exec Command="lessc &quot;%(AdditionalFile.Identity)&quot; &quot;@(AdditionalFile->'%(RelativeDir)%(Filename).css')&quot; $(LessParams)" />

We want to run the LESS compiler for each file specified in the AdditionalFile item group. However, MSBuild doesn't let you create foreach loops to access each item. Instead, if you access a meta data inside a task, MSBuild executes that task for each unique meta data in the item group. This goes for every task, not just Exec. This mechanism is called Task Batching.

As a result, although you can't see an explicit foreach construct, the Exec task above is in fact executed for each file in the AdditionalFile item group.

We've already seen how @(AdditionalFile->'%(RelativeDir)%(Filename).css') is a transform that transforms the LESS file names in the AdditionalFile item group to CSS file names.

The first parameter to the lessc program is the LESS file names themselves. This uses the metadata Identity, which as you've guessed simply returns the items. Because it is a meta data  though, you do get the task batching you're after.

Publishing

   <Target Name="CompileLess" 
          Inputs="**\*.less" 
          Outputs="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" 
          AfterTargets="Build">
    <Exec Command="lessc &quot;%(AdditionalFile.Identity)&quot; &quot;@(AdditionalFile->'%(RelativeDir)%(Filename).css')&quot; $(LessParams)" />
	
    <ItemGroup>
      <None Include="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
    <ItemGroup>
      <Content Include="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" />
      <Content Remove="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
  </Target>

What about the item group within the CompileLess target? This is used not to compile the LESS files, but to implement publishing.

Visual Studio lets you publish your site (except for the free Express version). This means that with a few clicks you can copy all files required to run your site to the web server or a directory in your file system. Very handy. Right click on a project containing a web site and choose Publish.

The challenge is to get Visual Studio to copy the generated CSS files. But we don't want it to copy any of the LESS files, because they are meaningless to the browser.

The easiest way to get this done is to use the fact that Visual Studio publishes all files with build action Content, while excluding all files with build action None. You can see the build actions of your files in Visual Studio - right click a CSS or JavaScript file and choose Properties.

Inside the MSBuild project, all files with build action Content sit in the item group Content, while all files with build action None sit in the item group None. The solution is to move all LESS files that are in the Content item group to the None item group, so they won't get published. And to add the generated CSS files to the Content item group, so they will get published:

  1. <None Include="@(Content)" Condition="'%(Extension)'=='.less'" />

    Take all files in the Content item group, select the ones ending in .less, and include them in the None item group.

  2. <Content Include="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" />

    Include all generated CSS files in the Content item group.

  3. <Content Remove="@(Content)" Condition="'%(Extension)'=='.less'" />

    Take all files in the Content item group, select the ones ending in .less, and remove them from the Content item group.

For a publish operation to pick up these changes, they need to be done for each build.

Interestingly, these changes to the Content and None item groups are not reflected in Visual Studio's Solution Explorer. They are also not saved to the solution file when you do a save.

Cleaning

Cleaning your project simply means deleting all generated files. To clean your project in Visual Studio, choose Clean from the Build menu item.

Now that we're generating files of our own, we need to extend the built in clean to make sure the generated CSS files are deleted as well. There are two alternative ways to do this:

  1. Add the generated files to the FileWrites item group. This is easy, but only works if these conditions are met:
    • The generated files must sit under the output directory. This is the bin directory, as stored in property $(OutDir).
    • The generated files must be added to FileWrites before the Clean, or IncrementalClean, target executes.
  2. Delete the files off the file system with the Delete task.

In our case, the generated CSS files do not sit under the bin directory because they are not binaries. So we'll have to use the second alternative, using this simple target:

  <Target Name="CleanGeneratedCss" AfterTargets="Clean">
    <Delete Files="@(AdditionalFile->'%(RelativeDir)%(Filename).css')" />
  </Target>

Using the AfterTargets attribute, we get this target to run after the Clean target has been run by Visual Studio. We then use task batching with the built in Delete task to delete all generated CSS files.

Option 4. Directory per CSS file with LESS files, each directory matching a wildcard

To express option 4 in MSBuild definitions and implement Clean and Publish for your compiled CSS files, you could add the following to to your .csproj or .vbproj project file. Edit the file in a text editor such as Notepad, and add the following to the end, just before the closing </Project> tag:

  <!-- +++++++++++++++++++++ lessc parameters++++++++++++++++++++++++++++ -->
  
  <PropertyGroup>
    <LessParams Condition=" '$(Configuration)' == 'Debug' ">--line-numbers=mediaquery --relative-urls</LessParams>
    <LessParams Condition=" '$(Configuration)' == 'Release' ">--yui-compress --relative-urls</LessParams>
  </PropertyGroup>
  
  <!-- ++++++++++++++++++++++ Define properties +++++++++++++++++++++++++ -->
  
  <PropertyGroup>
    <!-- directory that holds all CSS and LESS files -->
    <StylesDir>$(ProjectDir)Content</StylesDir>

    <!-- 
    The regexp matching all less files in all directories to compile into CSS files.
    This regexp matches all .less files in directories with a name ending in Section.
    -->
    <DirectoryWildcard>\\.*Section\\.*\.less</DirectoryWildcard>

    <!-- format string used to convert directory name to CSS file name. {0} will be replaced by directory name. -->
    <CssFileNameFormat>Combined{0}.css</CssFileNameFormat>
  </PropertyGroup>
  
  <!-- ++++++++++++++++++++++++ Build +++++++++++++++++++++++++ -->
  
  <Target Name="CreateItemGroup">
    <ItemGroup>
      <LessPath Include="@(Content);@(None)" Condition="$([System.Text.RegularExpressions.Regex]::IsMatch(&quot;%(Identity)&quot;, &quot;$(DirectoryWildcard)&quot;))">
        <DirectoryName>$([MSBuild]::MakeRelative("$(StylesDir)", "$(ProjectDir)%(RelativeDir)").TrimEnd(char[] {'\'}).Replace("\", "__"))</DirectoryName>
        <RelativeLessUrl>$([MSBuild]::MakeRelative("$(StylesDir)", "$(ProjectDir)%(Identity)").Replace('\', '/'))</RelativeLessUrl>
        <TemporaryLessPath>$(StylesDir)\__%(LessPath.DirectoryName).less</TemporaryLessPath>
        <CssPath>$(StylesDir)\$([System.String]::Format("$(CssFileNameFormat)", "%(LessPath.DirectoryName)"))</CssPath>
      </LessPath>
    </ItemGroup>
  </Target>
  
  <Target Name="CompileLess" AfterTargets="Build" DependsOnTargets="CreateItemGroup">
    <Delete Files="%(LessPath.TemporaryLessPath)" />
    <WriteLinesToFile File="%(LessPath.TemporaryLessPath)" Lines="@import-once &quot;%(LessPath.RelativeLessUrl)&quot;%3b" Overwrite="false" Encoding="UTF-8" />
    <Exec Command="lessc &quot;%(LessPath.TemporaryLessPath)&quot; &quot;%(LessPath.CssPath)&quot; $(LessParams)" />
    <Delete Files="%(LessPath.TemporaryLessPath)" />

    <ItemGroup>
      <None Include="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
    <ItemGroup>
      <Content Include="$([MSBuild]::MakeRelative(&quot;$(ProjectDir)&quot;, &quot;%(LessPath.CssPath)&quot;))" />
      <Content Remove="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
  </Target>
  
  <!-- ++++++++++++++++++++++++ Clean +++++++++++++++++++++++++ -->
  
  <Target Name="CleanGeneratedCss" AfterTargets="Clean" DependsOnTargets="CreateItemGroup">
    <Delete Files="%(LessPath.CssPath)" />
  </Target>

Lets hack this into manageable pieces and see how each piece works.

Using property elements to set LESS compiler options

  <PropertyGroup>
    <LessParams Condition=" '$(Configuration)' == 'Debug' ">--line-numbers=mediaquery --relative-urls</LessParams>
    <LessParams Condition=" '$(Configuration)' == 'Release' ">--yui-compress --relative-urls</LessParams>
  </PropertyGroup>

This is the same as for option 1.

Using property elements to define the directory wildcard and other key values

  <PropertyGroup>
    <!-- directory that holds all CSS and LESS files -->
    <StylesDir>$(ProjectDir)Content</StylesDir>

    <!-- 
    The regexp matching all less files in all directories to compile into CSS files.
    This regexp matches all .less files in directories with a name ending in Section.
    -->
    <DirectoryWildcard>\\.*Section\\.*\.less</DirectoryWildcard>

    <!-- format string used to convert directory name to CSS file name. {0} will be replaced by directory name. -->
    <CssFileNameFormat>Combined{0}.css</CssFileNameFormat>
  </PropertyGroup>

Option 4 involves finding all directories with LESS files using a wildcard. It is good to store this in a property up front instead of burying it in MSBuild definitions, to make it easier to change. That's been done here, along with a few other key values.

Using an item group to define what LESS files to compile

In order to iterate over the directories with the LESS files, we need to create an item group:

  <Target Name="CreateItemGroup">
    <ItemGroup>
      <LessPath Include="@(Content);@(None)" Condition="$([System.Text.RegularExpressions.Regex]::IsMatch(&quot;%(Identity)&quot;, &quot;$(DirectoryWildcard)&quot;))">
        <DirectoryName>$([MSBuild]::MakeRelative("$(StylesDir)", "$(ProjectDir)%(RelativeDir)").TrimEnd(char[] {'\'}).Replace("\", "__"))</DirectoryName>
        <RelativeLessUrl>$([MSBuild]::MakeRelative("$(StylesDir)", "$(ProjectDir)%(Identity)").Replace('\', '/'))</RelativeLessUrl>
        <TemporaryLessPath>$(StylesDir)\__%(LessPath.DirectoryName).less</TemporaryLessPath>
        <CssPath>$(StylesDir)\$([System.String]::Format("$(CssFileNameFormat)", "%(LessPath.DirectoryName)"))</CssPath>
      </LessPath>
    </ItemGroup>
  </Target>

Lets disect this.

What we've got here is a target CreateItemGroup which creates item group LessPath. When you use Condition in an item group outside a target, you'll find that the item group is always completely empty when you try to access it. Later on we'll see how we'll have to execute CreateItemGroup  in order to create the item group.

At the heart of the matter is item group LessPath:

  <LessPath Include="@(Content);@(None)" Condition="$([System.Text.RegularExpressions.Regex]::IsMatch(&quot;%(Identity)&quot;, &quot;$(DirectoryWildcard)&quot;))">

This item group contains the LESS files in the directories that we want to generate into CSS files - one CSS file per directory, the contents of all LESS files in a directory goes into the corresponding CSS file.

However, we're only interested in those LESS files that are actually part of the project. Those LESS files can be found in the Content and None item groups which we saw before.

To make this all happen, the LessPath item group includes the files in the Content and None item groups, but only those whose file paths match the regular expression in the DirectoryWildcard property:

  <LessPath Include="@(Content);@(None)" Condition="$([System.Text.RegularExpressions.Regex]::IsMatch(&quot;%(Identity)&quot;, &quot;$(DirectoryWildcard)&quot;))" >

How do we do a regular expression match in MSBuild? Simple. MSBuild allows you to use static methods exposed by .Net classes, using static property functions. These take the form:

$([Class]::Method(Parameters))

So to match a string against a regular expression, we can use the static method IsMatch exposed by the .Net Regex class in namespace System.Text.RegularExpressions. The first parameter of IsMatch is the file path to match against the regular expression, which we get from the Identity meta. The second parameter is the regular expression itself in property DirectoryWildcard.

<DirectoryName>$([MSBuild]::MakeRelative("$(StylesDir)", "$(ProjectDir)%(RelativeDir)").TrimEnd(char[] {'\'}).Replace("\", "__"))</DirectoryName>

In the definitions below, we'll need information based on the directory names themselves. If the LESS files are in directories D:\Dev\Website\Content\AdminSection and D:\Dev\Website\Content\MainSection, we need to wind up with AdminSection and MainSection. So we'll add a meta data that:

  • Takes the directory part of the LESS files using meta data RelativeDir,
  • Substracts the part common to all CSS and LESS files using MakeRelative, a static method provided by MSBuild.
  • Removes the trailing backslash using TrimEnd. This uses the fact that MakeRelative returns strings, on which we can apply string methods.
  • Replaces backslashes by double underscores. This caters for the fact that our selected directories may have subdirectories, which will be generated into their own CSS files.
<RelativeLessUrl>$([MSBuild]::MakeRelative("$(StylesDir)", "$(ProjectDir)%(Identity)").Replace('\', '/'))</RelativeLessUrl>

We'll be writing @import-once directives for each LESS file, such as @import-once "AdminSection/Login.less";. To do this easily, we'll add meta data RelativeLessUrl which:

  • Takes the full path of each file using meta data Identity.
  • Substracts the part common to all CSS and LESS files using MakeRelative.
  • Replaces the backslashes with forward slashes using the String method Replace, as demanded by the @import-once directive.
<TemporaryLessPath>$(StylesDir)\__%(LessPath.DirectoryName).less</TemporaryLessPath>

To compile the CSS files, we'll write the @import-once directives to temporary LESS files. This meta data gives use the names of these temporary files, based on the directory names.

<CssPath>$(StylesDir)\$([System.String]::Format("$(CssFileNameFormat)", "%(LessPath.DirectoryName)"))</CssPath>

Finally, we need a meta data that gives us the CSS file names. These are based on the names of the directories. This uses the static String method Format and the format string stored in the CssFileNameFormat property.

Using a target to actually compile the LESS files

With all the ground work done, the target that actually compiles the LESS files is quite simple:

  <Target Name="CompileLess" AfterTargets="Build" DependsOnTargets="CreateItemGroup">
    <Delete Files="%(LessPath.TemporaryLessPath)" />
    <WriteLinesToFile File="%(LessPath.TemporaryLessPath)" Lines="@import-once &quot;%(LessPath.RelativeLessUrl)&quot;%3b" Overwrite="false" Encoding="UTF-8" />
    <Exec Command="lessc &quot;%(LessPath.TemporaryLessPath)&quot; &quot;%(LessPath.CssPath)&quot; $(LessParams)" />
    <Delete Files="%(LessPath.TemporaryLessPath)" />

    <ItemGroup>
      <None Include="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
    <ItemGroup>
      <Content Include="$([MSBuild]::MakeRelative(&quot;$(ProjectDir)&quot;, &quot;%(LessPath.CssPath)&quot;))" />
      <Content Remove="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
  </Target>

Remember that the idea here is that:

  • The Content directory contains one or more sub directories matching a wildcard, such as *Section. This was done with the property element DirectoryWildcard and the item group LessPath.
  • For each directory, the script creates a temporary LESS file with @import-once directives for each LESS file in the directory. That is done here using the task WriteLinesToFile.
  • It then compiles the temporary LESS files into CSS files, one CSS file per directory, using the Exec task.
<Target Name="CompileLess" AfterTargets="Build" DependsOnTargets="CreateItemGroup">

The CompileLess target accesses the LessPath item group, which is created by item group CreateItemGroup. The DependsOnTargets attribute ensures that CreateItemGroup gets executed before CompileLess.

There are no new MSBuilds concepts here, except for a few tidbits related to writing the @import-once directives to the temporary files:

    <WriteLinesToFile File="%(LessPath.TemporaryLessPath)" Lines="@import-once &quot;%(LessPath.RelativeLessUrl)&quot;%3b" Overwrite="false" Encoding="UTF-8" />

The built in task WriteLinesToFile lets you write lines to a file. By setting its Overwrite parameter to false, you ensure that it appends to the file, rather than overwriting it.

As you see, we're setting the File parameter to the contents of the TemporaryLessPath meta data, which has the names of the temporary LESS files. And the Lines parameter is set to the line that we want to write - in this case an @import-once directive of the form:

@import-once "directory/file.less";

One issue here is the quotes - they need to be escaped because the entire line itself sits within quotes. We can easily do that by using the HTML entity &quot;.

Another issue is the semicolon at the end. We can't simply write the semicolon itself, because WriteLinesToFile interprets the semicolon as a newline. Using the HTML entity for semicolon (&#59;) won't work either. Instead, MSBuild wants you to escape special characters by writing %xx, where xx is the hexadecimal ASCII code of the character you're after. For semicolon, that's %3b.

Finally, there is the character encoding issue. Because we're writing a text file that will be interpreted by the lessc program, we want to use UTF-8. However, WriteLinesToFile by default uses UTF-16, which lessc doesn't like. So we use the Encoding parameter to tell WriteLinesToFile to use UTF-8.

Publishing

  <Target Name="CompileLess" AfterTargets="Build" DependsOnTargets="CreateItemGroup">
    <Delete Files="%(LessPath.TemporaryLessPath)" />
    <WriteLinesToFile File="%(LessPath.TemporaryLessPath)" Lines="@import-once &quot;%(LessPath.RelativeLessUrl)&quot;%3b" Overwrite="false" Encoding="UTF-8" />
    <Exec Command="lessc &quot;%(LessPath.TemporaryLessPath)&quot; &quot;%(LessPath.CssPath)&quot; $(LessParams)" />
    <Delete Files="%(LessPath.TemporaryLessPath)" />

    <ItemGroup>
      <None Include="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
    <ItemGroup>
      <Content Include="$([MSBuild]::MakeRelative(&quot;$(ProjectDir)&quot;, &quot;%(LessPath.CssPath)&quot;))" />
      <Content Remove="@(Content)" Condition="'%(Extension)'=='.less'" />
    </ItemGroup>
  </Target>

Publishing is catered for here in a very similar way to the version for option 1: Move all LESS files from the Content item group to the None item group, and include all generated CSS files in the Content item group. We do need to make sure that when adding the file paths of the generated CSS files to the Content item group, those paths are relative to the project directory. If you make them absolute, they won't be processed during the Publish.

Cleaning

The target for cleaning is very similar to the version for option 1 as well. Seeing that this target accesses item group LessPath, we'll need to ensure item group CreateItemGroup is executed first. That is done here using  the DependsOnTargets attribute.

   <Target Name="CleanGeneratedCss" AfterTargets="Clean" DependsOnTargets="CreateItemGroup">
    <Delete Files="%(LessPath.CssPath)" />
  </Target>

Add comment

  Country flag

biuquote
  • Comment
  • Preview
Loading

Books

Book: ASP.NET Site Performance Secrets

ASP.NET Site Performance Secrets

By Matt Perdeck

Details and Purchase

About Matt Perdeck

Matt Perdeck PresentingMatt has written extensively on .Net and client side software development.

more >>