25

I'm trying to have LESS files in my web project, and have the MVC 4 bundling functionality call into the dotLess library to turn the LESS into CSS, then minify the result and give it to the browser.

I found an example on the ASP.NET site (under the heading LESS, CoffeeScript, SCSS, Sass Bundling.). This has given me a LessTransform class that looks like this:

public class LessTransform : IBundleTransform
{
    public void Process(BundleContext context, BundleResponse response)
    {
        response.Content = dotless.Core.Less.Parse(response.Content);
        response.ContentType = "text/css";
    }
}

and this line in my BundleConfig class:

bundles.Add(new Bundle(
    "~/Content/lessTest", 
    new LessTransform(), 
    new CssMinify()).Include("~/Content/less/test.less"));

finally I have the following line in my _Layout.cshtml, in the <head>:

@Styles.Render("~/Content/lessTest")

If I have the site in debug mode, this is rendered to the browser:

<link href="/Content/less/test.less" rel="stylesheet"/>

The rules in the .less file are applied, and following that link shows that the LESS has been correctly transformed into CSS.

However, if I put the site into release mode, this is rendered out:

<link href="/Content/less?v=lEs-HID6XUz3s2qkJ35Lvnwwq677wTaIiry6fuX8gz01" rel="stylesheet"/>

The rules in the .less file are not applied, because following the link gives a 404 error from IIS.

So it seems that something is going wrong with the bundling. How do I get this to work in release mode, or how do I find out what exactly is going wrong?

Graham Clark
  • 12,886
  • 8
  • 50
  • 82
  • Are you running in release mode on the same machine, or publishing to another box? – 3Dave Mar 06 '13 at 16:34
  • @DavidLively I believe that is happening because in Debug mode, no minifcation or concatenation of files takes place, each individual file is output as a separate `` just to make things easier to debug. It's only in Release mode that the minifying and bundling happen. I'm just running this using IIS Express, just changing the `` item in the web.config. – Graham Clark Mar 06 '13 at 16:37
  • @DavidLively the behaviour is the same if I publish to somewhere else on my pc and then setup a proper website in IIS (7). – Graham Clark Mar 06 '13 at 17:05
  • 2
    Maybe this answers your problem http://stackoverflow.com/questions/12081255/asp-net-mvc-framework-4-5-css-bundles-does-not-work-on-the-hosting – Jasen Mar 06 '13 at 17:48
  • @Jasen thanks, that did stop the 403 error - however it still doesn't work in release mode, I just get a 404 for the stylesheet link instead. – Graham Clark Mar 06 '13 at 18:52

5 Answers5

15

As a complement to the accepted answer, I created a LessBundle class, which is the Less eqivalent of the StyleBundle class.

LessBundle.cs code is:

using System.Web.Optimization;

namespace MyProject
{
    public class LessBundle : Bundle
    {
        public LessBundle(string virtualPath) : base(virtualPath, new IBundleTransform[] {new LessTransform(), new CssMinify()})
        {

        }

        public LessBundle(string virtualPath, string cdnPath)
            : base(virtualPath, cdnPath, new IBundleTransform[] { new LessTransform(), new CssMinify() })
        {

        }
    }
}

Usage is similar to the StyleBundle class, specifying a LESS file instead of a CSS file.

Add the following to your BundleConfig.RegisterBundles(BundleCollection) method:

bundles.Add(new LessBundle("~/Content/less").Include(
                 "~/Content/MyStyles.less"));

Update

This method works fine with optimization switched off, but I ran into some minor problems (with CSS resource paths) when optimization was switched on. After an hour researching the issue I discovered that I have reinvented the wheel...

If you do want the LessBundle functionality I describe above, check out System.Web.Optimization.Less.

The NuGet package can be found here.

Community
  • 1
  • 1
David Kirkland
  • 2,431
  • 28
  • 28
  • 1
    The linked package apparently does not support Less 1.4.x or higher https://github.com/scott-xu/System.Web.Optimization.Less/issues/27 – Shane Courtrille Sep 15 '16 at 17:43
12

Edited 12/8/2019 This is no longer an acceptable answer to this issue as there have been breaking changes in ASP.NET over the years. There are other answers further down that have modified this code or supplied other answers to help you fix this issue.

It appears that the dotless engine needs to know the path of the currently processed bundle file to resolve @import paths. If you run the process code that you have above, the result of the dotless.Core.Less.Parse() is an empty string when the .less file being parsed has other less files imported.

Ben Foster's response here will fix that by reading the imported files first:

Import Files and DotLess

Change your LessTransform file as follows:

public class LessTransform : IBundleTransform
{
    public void Process(BundleContext context, BundleResponse bundle)
    {
        if (context == null)
        {
            throw new ArgumentNullException("context");
        }

        if (bundle == null)
        {
            throw new ArgumentNullException("bundle");
        }

        context.HttpContext.Response.Cache.SetLastModifiedFromFileDependencies();

        var lessParser = new Parser();
        ILessEngine lessEngine = CreateLessEngine(lessParser);

        var content = new StringBuilder(bundle.Content.Length);

        var bundleFiles = new List<FileInfo>();

        foreach (var bundleFile in bundle.Files)
        {
            bundleFiles.Add(bundleFile);

            SetCurrentFilePath(lessParser, bundleFile.FullName);
            string source = File.ReadAllText(bundleFile.FullName);
            content.Append(lessEngine.TransformToCss(source, bundleFile.FullName));
            content.AppendLine();

            bundleFiles.AddRange(GetFileDependencies(lessParser));
        }

        if (BundleTable.EnableOptimizations)
        {
            // include imports in bundle files to register cache dependencies
            bundle.Files = bundleFiles.Distinct();
        }

        bundle.ContentType = "text/css";
        bundle.Content = content.ToString();
    }

    /// <summary>
    /// Creates an instance of LESS engine.
    /// </summary>
    /// <param name="lessParser">The LESS parser.</param>
    private ILessEngine CreateLessEngine(Parser lessParser)
    {
        var logger = new AspNetTraceLogger(LogLevel.Debug, new Http());
        return new LessEngine(lessParser, logger, true, false);
    }

    /// <summary>
    /// Gets the file dependencies (@imports) of the LESS file being parsed.
    /// </summary>
    /// <param name="lessParser">The LESS parser.</param>
    /// <returns>An array of file references to the dependent file references.</returns>
    private IEnumerable<FileInfo> GetFileDependencies(Parser lessParser)
    {
        IPathResolver pathResolver = GetPathResolver(lessParser);

        foreach (var importPath in lessParser.Importer.Imports)
        {
            yield return new FileInfo(pathResolver.GetFullPath(importPath));
        }

        lessParser.Importer.Imports.Clear();
    }

    /// <summary>
    /// Returns an <see cref="IPathResolver"/> instance used by the specified LESS lessParser.
    /// </summary>
    /// <param name="lessParser">The LESS parser.</param>
    private IPathResolver GetPathResolver(Parser lessParser)
    {
        var importer = lessParser.Importer as Importer;
        var fileReader = importer.FileReader as FileReader;

        return fileReader.PathResolver;
    }

    /// <summary>
    /// Informs the LESS parser about the path to the currently processed file. 
    /// This is done by using a custom <see cref="IPathResolver"/> implementation.
    /// </summary>
    /// <param name="lessParser">The LESS parser.</param>
    /// <param name="currentFilePath">The path to the currently processed file.</param>
    private void SetCurrentFilePath(Parser lessParser, string currentFilePath)
    {
        var importer = lessParser.Importer as Importer;

        if (importer == null)
            throw new InvalidOperationException("Unexpected dotless importer type.");

        var fileReader = importer.FileReader as FileReader;

        if (fileReader == null || !(fileReader.PathResolver is ImportedFilePathResolver))
        {
            fileReader = new FileReader(new ImportedFilePathResolver(currentFilePath));
            importer.FileReader = fileReader;
        }
    }
}

public class ImportedFilePathResolver : IPathResolver
{
    private string currentFileDirectory;
    private string currentFilePath;

    public ImportedFilePathResolver(string currentFilePath)
    {
        if (string.IsNullOrEmpty(currentFilePath))
        {
            throw new ArgumentNullException("currentFilePath");
        }

        CurrentFilePath = currentFilePath;
    }

    /// <summary>
    /// Gets or sets the path to the currently processed file.
    /// </summary>
    public string CurrentFilePath
    {
        get { return currentFilePath; }
        set
        {
            currentFilePath = value;
            currentFileDirectory = Path.GetDirectoryName(value);
        }
    }

    /// <summary>
    /// Returns the absolute path for the specified improted file path.
    /// </summary>
    /// <param name="filePath">The imported file path.</param>
    public string GetFullPath(string filePath)
    {
        if (filePath.StartsWith("~"))
        {
            filePath = VirtualPathUtility.ToAbsolute(filePath);
        }

        if (filePath.StartsWith("/"))
        {
            filePath = HostingEnvironment.MapPath(filePath);
        }
        else if (!Path.IsPathRooted(filePath))
        {
            filePath = Path.GetFullPath(Path.Combine(currentFileDirectory, filePath));
        }

        return filePath;
    }
}
Emma Middlebrook
  • 836
  • 1
  • 9
  • 19
  • every thing work except in the bundle.Files = bundleFiles.Distinct(); line it give me this Error 'System.Collections.Generic.List' does not contain a definition for 'Distinct' and no extension method 'Distinct' accepting a first argument of type 'System.Collections.Generic.List' could be found (are you missing a using directive or an assembly reference?) – Fadi Jun 21 '14 at 20:21
  • @Fadi you need to add `using System.Linq;` to the top of your code. `Distinct` is an extension method found in that namespace. – David Sherret Jun 28 '14 at 19:40
  • 7
    `bundle.Files` is not a `List`; it's a `List`. This code didn't work for me. – mellis481 Sep 02 '14 at 17:38
  • This code is not working. How this answer can be validated ? – User.Anonymous Jan 30 '19 at 11:35
  • This code was written 6 years ago. You'll have to scroll down to other answers that have since been added to make this work with newer versions of ASP.NET – Emma Middlebrook Aug 12 '19 at 11:15
3

The accepted answer does not work with recent changes to ASP.NET, so is no longer correct.

I've fixed the source in the accepted answer:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Web.Hosting;
using System.Web.Optimization;
using dotless.Core;
using dotless.Core.Abstractions;
using dotless.Core.Importers;
using dotless.Core.Input;
using dotless.Core.Loggers;
using dotless.Core.Parser;

namespace Web.App_Start.Bundles
{
    public class LessTransform : IBundleTransform
    {
        public void Process(BundleContext context, BundleResponse bundle)
        {
            if (context == null)
            {
                throw new ArgumentNullException("context");
            }

            if (bundle == null)
            {
                throw new ArgumentNullException("bundle");
            }

            context.HttpContext.Response.Cache.SetLastModifiedFromFileDependencies();

            var lessParser = new Parser();
            ILessEngine lessEngine = CreateLessEngine(lessParser);

            var content = new StringBuilder(bundle.Content.Length);

            var bundleFiles = new List<BundleFile>();

            foreach (var bundleFile in bundle.Files)
            {
                bundleFiles.Add(bundleFile);

                var name = context.HttpContext.Server.MapPath(bundleFile.VirtualFile.VirtualPath);
                SetCurrentFilePath(lessParser, name);
                using (var stream = bundleFile.VirtualFile.Open())
                using (var reader = new StreamReader(stream))
                {
                    string source = reader.ReadToEnd();
                    content.Append(lessEngine.TransformToCss(source, name));
                    content.AppendLine();
                }

                bundleFiles.AddRange(GetFileDependencies(lessParser));
            }

            if (BundleTable.EnableOptimizations)
            {
                // include imports in bundle files to register cache dependencies
                bundle.Files = bundleFiles.Distinct();
            }

            bundle.ContentType = "text/css";
            bundle.Content = content.ToString();
        }

        /// <summary>
        /// Creates an instance of LESS engine.
        /// </summary>
        /// <param name="lessParser">The LESS parser.</param>
        private ILessEngine CreateLessEngine(Parser lessParser)
        {
            var logger = new AspNetTraceLogger(LogLevel.Debug, new Http());
            return new LessEngine(lessParser, logger, true, false);
        }

        /// <summary>
        /// Gets the file dependencies (@imports) of the LESS file being parsed.
        /// </summary>
        /// <param name="lessParser">The LESS parser.</param>
        /// <returns>An array of file references to the dependent file references.</returns>
        private IEnumerable<BundleFile> GetFileDependencies(Parser lessParser)
        {
            IPathResolver pathResolver = GetPathResolver(lessParser);

            foreach (var importPath in lessParser.Importer.Imports)
            {
                yield return
                    new BundleFile(pathResolver.GetFullPath(importPath),
                        HostingEnvironment.VirtualPathProvider.GetFile(importPath));
            }

            lessParser.Importer.Imports.Clear();
        }

        /// <summary>
        /// Returns an <see cref="IPathResolver"/> instance used by the specified LESS lessParser.
        /// </summary>
        /// <param name="lessParser">The LESS parser.</param>
        private IPathResolver GetPathResolver(Parser lessParser)
        {
            var importer = lessParser.Importer as Importer;
            var fileReader = importer.FileReader as FileReader;

            return fileReader.PathResolver;
        }

        /// <summary>
        /// Informs the LESS parser about the path to the currently processed file. 
        /// This is done by using a custom <see cref="IPathResolver"/> implementation.
        /// </summary>
        /// <param name="lessParser">The LESS parser.</param>
        /// <param name="currentFilePath">The path to the currently processed file.</param>
        private void SetCurrentFilePath(Parser lessParser, string currentFilePath)
        {
            var importer = lessParser.Importer as Importer;

            if (importer == null)
                throw new InvalidOperationException("Unexpected dotless importer type.");

            var fileReader = importer.FileReader as FileReader;

            if (fileReader == null || !(fileReader.PathResolver is ImportedFilePathResolver))
            {
                fileReader = new FileReader(new ImportedFilePathResolver(currentFilePath));
                importer.FileReader = fileReader;
            }
        }
    }
}

Please note one known issue with this code as is is that LESS @imports must use their full paths, i.e. you must use @import "~/Areas/Admin/Css/global.less"; instead of @import "global.less";.

Ian Newson
  • 7,679
  • 2
  • 47
  • 80
  • A few notes for others: 1. You have to copy "ImportedFilePathResolver" from the code in the accepted answer. It is the class right at the bottom. 2. The @import code is inside the .less files. – Atron Seige Nov 13 '15 at 13:28
2

Looks like this works - I changed the Process method to iterate over the file collection:

public void Process(BundleContext context, BundleResponse response)
{
    var builder = new StringBuilder();
    foreach (var fileInfo in response.Files)
    {
        using (var reader = fileInfo.OpenText())
        {
            builder.Append(dotless.Core.Less.Parse(reader.ReadToEnd()));
        }
    }

    response.Content = builder.ToString();
    response.ContentType = "text/css";
}

This breaks if there are any @import statements in your less files though, in this case you have to do a bit more work, like this: https://gist.github.com/chrisortman/2002958

Graham Clark
  • 12,886
  • 8
  • 50
  • 82
1

Already some great answers, here's a very simple solution I found for myself when trying to add MVC bundles that regard less files.

After creating your less file (for example, test.less), right click on it and under Web Compiler (get it here) option, select Compile File.

This generates the resulting css file from your less one, and also its minified version. (test.css and test.min.css).

On your bundle, just refer to the generated css file

style = new StyleBundle("~/bundles/myLess-styles")
    .Include("~/Content/css/test.css", new CssRewriteUrlTransform());

bundles.Add(style);

And on your view, reference that bundle:

@Styles.Render("~/bundles/myLess-styles")

It should just work fine.

chiapa
  • 4,362
  • 11
  • 66
  • 106
  • It doesn't work for me. When I have EnableOptimizations set to false, everything is OK. When I set this to true, less files don't work anymore. What is BundleCacheTransform() ? – FrenkyB Jun 24 '16 at 10:59
  • @FrenkyB, sorry I didn't realize I left 'BundleCacheTransform()' there, that is something we created for a specific project, shouldn't interfere with the outcome – chiapa Jun 24 '16 at 16:24
  • Just wanted to add that Web compiler is definitely solution to my problem. Works like a charm and it's very easy to use. Demands no extra installing like dotLess. – FrenkyB Jun 24 '16 at 16:53
  • Web Compiler seems to only support VS 2015 and 17 -_- .. I have been telling my team to upgrade VS since I started 3 yrs ago. We are still on VS10. – eaglei22 Jun 22 '18 at 13:42