52

I'm trying to use MVC4 bundling to group some of my less files, but it looks like the import path I'm using is off. My directory structure is:

static/
    less/
        mixins.less
        admin/
            user.less

In user.less, I'm attempting to import mixins.less using this:

@import "../mixins.less";

This used to work for me before when using chirpy with dotless, but now I noticed ELMAH was getting mad at me, saying this:

System.IO.FileNotFoundException: 
    You are importing a file ending in .less that cannot be found.
File name: '../mixins.less'

Am I supposed to use a different @import with MVC4?

Some additional info

Here's the less class and global.asax.cs code I'm using to attempt this:

LessMinify.cs

...
public class LessMinify : CssMinify
{
    public LessMinify() {}

    public override void Process(BundleContext context, BundleResponse response)
    {
        response.Content = Less.Parse(response.Content);
        base.Process(context, response);
    }
}
...

Global.asax.cs

...
DynamicFolderBundle lessFB = 
    new DynamicFolderBundle("less", new LessMinify(), "*.less");
    
BundleTable.Bundles.Add(lessFB);

Bundle AdminLess = new Bundle("~/AdminLessBundle", new LessMinify());
...
AdminLess.AddFile("~/static/less/admin/user.less");
BundleTable.Bundles.Add(AdminLess);
...
Community
  • 1
  • 1
JesseBuesking
  • 6,496
  • 4
  • 44
  • 89

10 Answers10

41

I've written a quick blog post about Using LESS CSS With MVC4 Web Optimization.

It basically boils down to using the BundleTransformer.Less Nuget Package and changing up your BundleConfig.cs.

Tested with bootstrap.

EDIT: Should mention the reason I say this, is I also ran into the @import directory structure issue, and this library handles it correctly.

Ben Cull
  • 9,434
  • 7
  • 43
  • 38
  • 5
    thanks for this, I was going quietly crazy and not finding what I needed. Can't believe this post had not been voted up. I have added a post with an addendum to your solution. – awrigley Oct 31 '12 at 17:29
  • 6
    I wouldn't call it simple nor elegant. BundleTransformer is a very heavy Nuget package if all you're using is LESS. (actually 5+ nuget packages, and requires installing IE9+ on your web servers). Michael Baird's answer is far simpler – arserbin3 Sep 19 '13 at 15:13
  • Ben, I've followed your blog post's instructions, as well as the samples given on the BundleTransformer.Less documentation page, and I'm still getting errors about dotless not being able to find a file referenced by an `@import` statement. All of my files are in the same directory, so I'm just using `@import url("filename.less");` I even followed this post: http://geekswithblogs.net/ToStringTheory/archive/2012/11/30/who-could-ask-for-more-with-less-css-part-2.aspx Any ideas what may be happening? MVC4/.NET 4.5 – ps2goat Oct 18 '13 at 22:08
  • @ps2goat A stab in the dark, but have you replaced the two web.config sections it asks you to replace when you install the BundleTransformer nuget package? – Ben Cull Oct 30 '13 at 00:57
  • I think it was all of the requirements that weren't automatically downloaded via the nuget package. we ended up going with @MichaelBaird's solution because, as others have said, it didn't require IE 9+ or Visual C++ libraries (depending on your version of the javascript engine switcher). – ps2goat Oct 30 '13 at 09:13
  • Adding css.Transforms.Add(cssTransformer); in BundleConfig,cs worked for me. Thanks! – VladN Jul 23 '14 at 08:53
26

There is code posted on GitHub Gist that works well with @import and dotLess: https://gist.github.com/2002958

I tested it with Twitter Bootstrap and it works well.

ImportedFilePathResolver.cs

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

    /// <summary>
    /// Initializes a new instance of the <see cref="ImportedFilePathResolver"/> class.
    /// </summary>
    /// <param name="currentFilePath">The path to the currently processed file.</param>
    public ImportedFilePathResolver(string 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)
    {
        filePath = filePath.Replace('\\', '/').Trim();

        if(filePath.StartsWith("~"))
        {
            filePath = VirtualPathUtility.ToAbsolute(filePath);
        }

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

        return filePath;
    }
}

LessMinify.cs

public class LessMinify : IBundleTransform
{
    /// <summary>
    /// Processes the specified bundle of LESS files.
    /// </summary>
    /// <param name="bundle">The LESS bundle.</param>
    public void Process(BundleContext context, BundleResponse bundle)
    {
        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);

        foreach(FileInfo file in bundle.Files)
        {
            SetCurrentFilePath(lessParser, file.FullName);
            string source = File.ReadAllText(file.FullName);
            content.Append(lessEngine.TransformToCss(source, file.FullName));
            content.AppendLine();

            AddFileDependencies(lessParser);
        }

        bundle.Content = content.ToString();
        bundle.ContentType = "text/css";
        //base.Process(context, bundle);
    }

    /// <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, false);
    }

    /// <summary>
    /// Adds imported files to the collection of files on which the current response is dependent.
    /// </summary>
    /// <param name="lessParser">The LESS parser.</param>
    private void AddFileDependencies(Parser lessParser)
    {
        IPathResolver pathResolver = GetPathResolver(lessParser);

        foreach(string importedFilePath in lessParser.Importer.Imports)
        {
            string fullPath = pathResolver.GetFullPath(importedFilePath);
            HttpContext.Current.Response.AddFileDependency(fullPath);
        }

        lessParser.Importer.Imports.Clear();
    }

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

        return null;
    }

    /// <summary>
    /// Informs the LESS parser about the path to the currently processed file. 
    /// This is done by using 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)
        {
            var fileReader = importer.FileReader as FileReader;

            if(fileReader == null)
            {
                importer.FileReader = fileReader = new FileReader();
            }

            var pathResolver = fileReader.PathResolver as ImportedFilePathResolver;

            if(pathResolver != null)
            {
                pathResolver.CurrentFilePath = currentFilePath;
            }
            else
            {
               fileReader.PathResolver = new ImportedFilePathResolver(currentFilePath);
            }
        }
        else
        {
            throw new InvalidOperationException("Unexpected importer type on dotless parser");
        }


    }
}
Michael Baird
  • 1,329
  • 12
  • 19
  • Getting an error when I try to open your solution. nuget.targets was not found. – sheldonj Aug 09 '12 at 00:00
  • 1
    This was exactly what I was looking for. Great post Michael! – Reaction21 Aug 24 '12 at 04:51
  • There's a slightly improved version that claims to work for .net 4.5 as well: https://github.com/dotless/dotless/issues/148 https://bitbucket.org/mrcode/bundlingsandbox/changeset/fbfd73a148e6816a366d9c16051fe113990e0b8f – ATL_DEV Sep 29 '12 at 02:18
  • 6
    Something may have changed in .net 4.5 but the above code does not cache imports correctly. To ensure cache dependencies are configured correctly you need to add all import paths to the `Bundle.Files` collection when optimizations are enabled. My working code - https://gist.github.com/3924025 – Ben Foster Oct 20 '12 at 18:45
  • See Ben Cull's answer and vote it up. It is the one that works in the modern age, with BundleTransform.Less nuget package. No pain, just works. – awrigley Oct 31 '12 at 17:39
  • dead links for the demo project – Alex Apr 08 '15 at 08:03
  • In my code `bundle.Files` doesn't return `FileInfo`. Have to call this to get file path `file.IncludedVirtualPath.Replace("~/", HttpRuntime.AppDomainAppPath);` – Hp93 May 03 '17 at 04:42
22

Addendum to Ben Cull's answer:

I know that this "should be a comment to Ben Cull's post", but it adds a little extra that would be impossible to add in a comment. So vote me down if you must. Or close me.

Ben's blog post does it all, except it doesn't specify minification.

So install the BundleTransformer.Less package as Ben suggests and then, if you want minification of your css, do the following (in ~/App_Start/BundleConfig.cs):

var cssTransformer = new CssTransformer();
var jsTransformer = new JsTransformer();
var nullOrderer = new NullOrderer();

var css = new Bundle("~/bundles/css")
    .Include("~/Content/site.less");
css.Transforms.Add(cssTransformer);
css.Transforms.Add(new CssMinify());
css.Orderer = nullOrderer;

bundles.Add(css);

The added line is:

css.Transforms.Add(new CssMinify());

Where CssMinify is in System.Web.Optimizations

I am so relieved to get around the @import issue and the resulting file with .less extension not found that I don't care who votes me down.

If, on the contrary, you feel the urge to vote for this answer, please give your vote to Ben.

So there.

awrigley
  • 13,481
  • 10
  • 83
  • 129
  • This works but it seems that the imported file is inlined multiple times (once for each import). That kinds of defeats the purpose of the whole bundling idea which is to reduce file size... – Clement Nov 11 '12 at 12:08
17

A work around that I found that was really helpful was to set the directory before running Less.Parse inside of the LessMinify.Process(). Here is how I did it:

public class LessTransform : IBundleTransform
    {
        private string _path;

        public LessTransform(string path)
        {
            _path = path;
        }

        public void Process(BundleContext context, BundleResponse response)
        {
            Directory.SetCurrentDirectory(_path);

            response.Content = Less.Parse(response.Content);
            response.ContentType = "text/css";
        }
    }

Then passing in the path when creating the less transform object like so:

lessBundle.Transforms.Add(
    new LessTransform(HttpRuntime.AppDomainAppPath + "/Content/Less")
);

Hope this helps.

Chris Peterson
  • 713
  • 7
  • 13
4

The issue is that the DynamicFolderBundle reads all the contents of the files and passes the combined contents to the LessMinify.

As such any @imports have no reference to the location the file came from.

To resolve this I had to place all the "less" files into one location.

Then you have to understand the ordering of the files become important. As such I started to rename the file with a number (eg: "0 CONSTANTS.less", "1 MIXIN.less" which means that they are loaded at the top of the combined output before they go into the LessMinify.

if you debug your LessMinify and view the response.Content you will see the combined less output!

Hope this helps

TheRealQuagmire
  • 411
  • 1
  • 3
  • 10
  • This didn't seem to help. I have 0colors.less and am using bundle.AddDirectory to load all my less files which are in the same folder. @import "0colors.less" throws the same error. – Shane Courtrille Mar 23 '12 at 19:50
  • in the global.asax.cs i have: DynamicFolderBundle lessFb = new DynamicFolderBundle("less", new LessMinify(), "*.less"); BundleTable.Bundles.Add(lessFb); then use the path /static/less/admin/less (as the example above) to get to the relative location. – TheRealQuagmire Mar 28 '12 at 11:43
3

Here's the simplest version of the code to handle this I could come up with:

public class LessTransform : IBundleTransform
{
    public void Process(BundleContext context, BundleResponse bundle)
    {
        var pathResolver = new ImportedFilePathResolver(context.HttpContext.Server);
        var lessParser = new Parser();
        var lessEngine = new LessEngine(lessParser);
        (lessParser.Importer as Importer).FileReader = new FileReader(pathResolver);

        var content = new StringBuilder(bundle.Content.Length);
        foreach (var bundleFile in bundle.Files)
        {
            pathResolver.SetCurrentDirectory(bundleFile.IncludedVirtualPath);
            content.Append(lessEngine.TransformToCss((new StreamReader(bundleFile.VirtualFile.Open())).ReadToEnd(), bundleFile.IncludedVirtualPath));
            content.AppendLine();
        }

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

public class ImportedFilePathResolver : IPathResolver
{
    private HttpServerUtilityBase server { get; set; }
    private string currentDirectory { get; set; }

    public ImportedFilePathResolver(HttpServerUtilityBase server)
    {
        this.server = server;
    }

    public void SetCurrentDirectory(string fileLocation)
    {
        currentDirectory = Path.GetDirectoryName(fileLocation);
    }

    public string GetFullPath(string filePath)
    {
        var baseDirectory = server.MapPath(currentDirectory);
        return Path.GetFullPath(Path.Combine(baseDirectory, filePath));
    }
}
John
  • 17,163
  • 16
  • 65
  • 83
  • This code worked for me. Anyway I made a little change on line 14 of your code: using (var stream = new StreamReader(bundleFile.VirtualFile.Open())) { content.Append(lessEngine.TransformToCss(stream.ReadToEnd(), bundleFile.IncludedVirtualPath)); }; otherwise after loading the page I could not make changes to the .less file because "it was being used by another process" – Mirko Lugano Sep 09 '16 at 09:08
2

Here's what I did:

Added Twitter Bootstrap Nuget module.

Added this to my _Layout.cshtml file:

<link href="@System.Web.Optimization.BundleTable.Bundles.ResolveBundleUrl("~/Content/twitterbootstrap/less")" rel="stylesheet" type="text/css" />

Note that I renamed my "less" folder to twitterbootstrap just to demonstrate that I could

Moved all the less files into a subfolder called "imports" except bootstrap.less and (for responsive design) responsive.less.

~/Content/twitterbootstrap/imports

Added a configuration in the web.config:

<add key="TwitterBootstrapLessImportsFolder" value="imports" />

Created two classes (slight modification of the class above):

using System.Configuration;
using System.IO;
using System.Web.Optimization;
using dotless.Core;
using dotless.Core.configuration;
using dotless.Core.Input;

namespace TwitterBootstrapLessMinify
{
    public class TwitterBootstrapLessMinify : CssMinify
    {
        public static string BundlePath { get; private set; }

        public override void Process(BundleContext context, BundleResponse response)
        {
            setBasePath(context);

            var config = new DotlessConfiguration(dotless.Core.configuration.DotlessConfiguration.GetDefault());
            config.LessSource = typeof(TwitterBootstrapLessMinifyBundleFileReader);

            response.Content = Less.Parse(response.Content, config);
            base.Process(context, response);
        }

        private void setBasePath(BundleContext context)
        {
            var importsFolder = ConfigurationManager.AppSettings["TwitterBootstrapLessImportsFolder"] ?? "imports";
            var path = context.BundleVirtualPath;

            path = path.Remove(path.LastIndexOf("/") + 1);

            BundlePath = context.HttpContext.Server.MapPath(path + importsFolder + "/");
        }
    }

    public class TwitterBootstrapLessMinifyBundleFileReader : IFileReader
    {
        public IPathResolver PathResolver { get; set; }
        private string basePath;

        public TwitterBootstrapLessMinifyBundleFileReader() : this(new RelativePathResolver())
        {
        }

        public TwitterBootstrapLessMinifyBundleFileReader(IPathResolver pathResolver)
        {
            PathResolver = pathResolver;
            basePath = TwitterBootstrapLessMinify.BundlePath;
        }

        public bool DoesFileExist(string fileName)
        {
            fileName = PathResolver.GetFullPath(basePath + fileName);

            return File.Exists(fileName);
        }

        public string GetFileContents(string fileName)
        {
            fileName = PathResolver.GetFullPath(basePath + fileName);

            return File.ReadAllText(fileName);
        }
    }
}

My implementation of the IFileReader looks at the static member BundlePath of the TwitterBootstrapLessMinify class. This allows us to inject a base path for the imports to use. I would have liked to take a different approach (by providing an instance of my class, but I couldn't).

Finally, I added the following lines to the Global.asax:

BundleTable.Bundles.EnableDefaultBundles();

var lessFB = new DynamicFolderBundle("less", new TwitterBootstrapLessMinify(), "*.less", false);
BundleTable.Bundles.Add(lessFB);

This effectively solves the problem of the imports not knowing where to import from.

PeteK68
  • 461
  • 5
  • 9
1

As of Feb 2013: Michael Baird's great solution was superceeded by the "BundleTransformer.Less Nuget Package" answer referred to in Ben Cull's post. Similar answer at: http://blog.cdeutsch.com/2012/08/using-less-and-twitter-bootstrap-in.html

Cdeutsch's blog & awrigley's post adding minification is good, but apparently not now the correct approach.

Someone else with the same solution got some answers from a BundleTransformer author: http://geekswithblogs.net/ToStringTheory/archive/2012/11/30/who-could-ask-for-more-with-less-css-part-2.aspx. See the comments at the bottom.

In summary use BundleTransformer.MicrosoftAjax instead of the built in built-in minifiers. e.g. css.Transforms.Add(new CssMinify()); replaced with css.Transforms.Add(new BundleTransformer.MicrosoftAjax());

RockResolve
  • 1,423
  • 21
  • 29
1

Following on from RockResolve below, to use the MicrosoftAjax minifier, reference it as the default CSS minifier in web.config as opposed to passing it in as an argument.

From https://bundletransformer.codeplex.com/wikipage/?title=Bundle%20Transformer%201.7.0%20Beta%201#BundleTransformerMicrosoftAjax_Chapter

To make MicrosoftAjaxCssMinifier the default CSS-minifier and MicrosoftAjaxJsMinifier the default JS-minifier, you need to make changes to the Web.config file. In defaultMinifier attribute of \configuration\bundleTransformer\core\css element must be set value equal to MicrosoftAjaxCssMinifier, and in same attribute of \configuration\bundleTransformer\core\js element - MicrosoftAjaxJsMinifier.

BarryF
  • 78
  • 1
  • 7
-1

I have been through the same problem, seeing the same error message. Looking for a solution on the internet brought me here. My problem was as follows:

Within a less file I had at some point an incorrect style which was giving me a warning. The less file couldn't be parsed. I got rid of the error message by removing the incorrect line.

I hope this helps someone.

aIKid
  • 26,968
  • 4
  • 39
  • 65
Memet Olsen
  • 4,578
  • 5
  • 40
  • 50