TweetShare on TwitterFacebookShare on FacebookGoogle+Share on Google+LinkedinShare on Linkedin

Razor Compile-time Minification for ASP.NET Core 2.0

By: Luke Latham (@guardrex)
Published: 8/13/2017 Last updated: 10/1/2017

Learn how to use the extensibility features of the Razor engine in ASP.NET Core 2.0 to minify Razor Pages and MVC Razor Views at compile time.

In this article:

Introduction

Compile-time minification of markup with Razor in ASP.NET Core 1.x has been possible but only with difficulty. In the 1.x release, there's no easy way to plug in a third-party minification library so that markup from a View file can be intercepted prior to the Razor engine processing it. Because there isn't a simple extensibility point to intercept View content, you're left the the challenge of replacing a large amount of the Razor engine. To minify the content, you work with the "chunks" of markup that the Razor engine uses, which are convienant for the Razor engine to process but difficult to manage for markup minification.

With the ASP.NET Core 2.0 Razor engine, the situation is much improved. An extensibility point is present that permits you to simply intercept View (or Razor Page) file content, minify the content, and pass the content on to the Razor engine for processing.

Prerequisites

Sample App

Download a fully working sample to accompany this post from the guardrex/RazorMinification repo at GitHub.

The Setup

First, choose a 3rd party library that's capable of markup minification. This demo uses WebMarkupMin.Core (GitHub) by Andrey Taritsyn (GitHub, Blog, Twitter). WebMarkupMin.Core is a good choice because it minifies both the markup and inlined styles and scripts.

Replace the RazorTemplateEngine overriding the CreateCodeDocument method (ref source) with an implementation that performs the following work:

Here's a complete sample implementation:

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Razor.Language;
using WebMarkupMin.Core;

public class CustomRazorTemplateEngine : RazorTemplateEngine
{
	// If pages fail due to missing attribute quotes, add:
	//
	// AttributeQuotesRemovalMode = HtmlAttributeQuotesRemovalMode.KeepQuotes,
	//
	// to the HtmlMinificationSettings.
	private HtmlMinifier _htmlMinifier = new HtmlMinifier(new HtmlMinificationSettings() {
		RemoveOptionalEndTags = false,
	});

	public CustomRazorTemplateEngine(RazorEngine engine, RazorProject project) : 
		base(engine, project)
	{
		Options.ImportsFileName = "_ViewImports.cshtml";
		Options.DefaultImports = GetDefaultImports();
	}

	public override RazorCodeDocument CreateCodeDocument(RazorProjectItem projectItem)
	{
		if (projectItem == null)
		{
			throw new ArgumentNullException($"{nameof(projectItem)} is null!");
		}

		if (!projectItem.Exists)
		{
			throw new InvalidOperationException($"{nameof(projectItem)} doesn't exist!");
		}

		Console.WriteLine();
		Console.WriteLine($"File: {projectItem.FileName}");

		using (var inputStream = projectItem.Read())
		{
			using (var reader = new StreamReader(inputStream))
			{
				var text = reader.ReadToEnd();

				if (projectItem.FileName != "_ViewStart.cshtml" && projectItem.FileName != "_ViewImports.cshtml")
				{
					var markup = string.Empty;
					var directives = string.Empty;
					var markupStart = text.IndexOf("\r\n<");
					if (markupStart != -1)
					{
						directives = text.Substring(0, markupStart + 2);
						markup = text.Substring(markupStart + 2);
					}
					else
					{
						markup = text;
					}
					text = directives + Minify(markup);
				}

				var byteArray = Encoding.UTF8.GetBytes(text);
				var minifiedInputStream = new MemoryStream(byteArray);

				var source = RazorSourceDocument.ReadFrom(minifiedInputStream, projectItem.PhysicalPath);
				var imports = GetImports(projectItem);

				return RazorCodeDocument.Create(source, imports);
			}
		}
	}

	private string Minify(string markup)
	{
		MarkupMinificationResult result = 
			_htmlMinifier.Minify(markup, string.Empty, Encoding.UTF8, true);
		
		if (result.Errors.Count == 0)
		{
			MinificationStatistics statistics = result.Statistics;
			if (statistics != null)
			{
				Console.WriteLine();
				Console.WriteLine($"Original size: {statistics.OriginalSize:N0} Bytes | Minified size: {statistics.MinifiedSize:N0} Bytes | Saved: {statistics.SavedInPercent:N2}%");
			}
			//Console.WriteLine($"{Environment.NewLine}Minified content:{Environment.NewLine}{Environment.NewLine}{result.MinifiedContent}");

			return result.MinifiedContent;
		}
		else
		{
			IList<MinificationErrorInfo> errors = result.Errors;

			Console.WriteLine();
			Console.WriteLine($"Found {errors.Count:N0} error(s):");

			foreach (var error in errors)
			{
				Console.WriteLine($" - Line {error.LineNumber}, Column {error.ColumnNumber}: {error.Message}");
			}

			return markup;
		}
	}

	private static RazorSourceDocument GetDefaultImports()
	{
		using (var stream = new MemoryStream())
		using (var writer = new StreamWriter(stream, Encoding.UTF8))
		{
			writer.WriteLine("@using System");
			writer.WriteLine("@using System.Collections.Generic");
			writer.WriteLine("@using System.Linq");
			writer.WriteLine("@using System.Threading.Tasks");
			writer.WriteLine("@using Microsoft.AspNetCore.Mvc");
			writer.WriteLine("@using Microsoft.AspNetCore.Mvc.Rendering");
			writer.WriteLine("@using Microsoft.AspNetCore.Mvc.ViewFeatures");
			writer.WriteLine("@inject global::Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper Html");
			writer.WriteLine("@inject global::Microsoft.AspNetCore.Mvc.Rendering.IJsonHelper Json");
			writer.WriteLine("@inject global::Microsoft.AspNetCore.Mvc.IViewComponentHelper Component");
			writer.WriteLine("@inject global::Microsoft.AspNetCore.Mvc.IUrlHelper Url");
			writer.WriteLine("@inject global::Microsoft.AspNetCore.Mvc.ViewFeatures.IModelExpressionProvider ModelExpressionProvider");
			writer.WriteLine("@addTagHelper Microsoft.AspNetCore.Mvc.Razor.TagHelpers.UrlResolutionTagHelper, Microsoft.AspNetCore.Mvc.Razor");
			writer.WriteLine("@addTagHelper Microsoft.AspNetCore.Mvc.Razor.TagHelpers.HeadTagHelper, Microsoft.AspNetCore.Mvc.Razor");
			writer.WriteLine("@addTagHelper Microsoft.AspNetCore.Mvc.Razor.TagHelpers.BodyTagHelper, Microsoft.AspNetCore.Mvc.Razor");
			writer.Flush();

			stream.Position = 0;
			return RazorSourceDocument.ReadFrom(stream, fileName: null, encoding: Encoding.UTF8);
		}
	}
}

Finally, wire up the Razor engine replacement when you build the host:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.Extensions.DependencyInjection;

namespace RazorMinification
{
	public class Program
	{
		public static void Main(string[] args)
		{
			BuildWebHost(args).Run();
		}
			
		public static IWebHost BuildWebHost(string[] args) =>
			WebHost.CreateDefaultBuilder(args)
				.ConfigureServices(services =>
				{
					services.AddMvc();
					services.AddSingleton<RazorTemplateEngine, CustomRazorTemplateEngine>();
				})
				.Configure(app =>
				{
					app.UseDeveloperExceptionPage();
					app.UseMvcWithDefaultRoute();
					app.UseStaticFiles();
				})
				.Build();
	}
}

WebMarkupMin.Core isn't so good at parsing Razor code in a Razor View or Page, so see the Index.cshtml Razor Page below and note how sections of Razor code are escaped from WebMarkupMin.Core by placing the code inside of escape tags:

<!--wmm:ignore-->...<!--/wmm:ignore-->

Also note that the use of <text> tags prevents extra spaces and newlines from the Razor line transitions from making their way into the rendered markup.

@page
@model IndexModel
@inject Microsoft.AspNetCore.Hosting.IHostingEnvironment Host
<!DOCTYPE html>
<html lang="en">
<head>
	<title>Razor Pages Minification App</title>
</head>
<body>
	<h1>Razor Pages Minification App</h1>
	<p>@Host.ApplicationName @Model.DT</p>
	<figure style="text-align:center;width:400px">
		<img alt="Jack Burton" src="image.png">
		<figcaption>
			Jack Burton (Kurt Russell)<br>
			<em>Big Trouble in Little China</em><br>
			©1986 
			<a href="http://www.foxmovies.com/">
				20th Century Fox
			</a>
		</figcaption>
	</figure>
	<p>
		People:
		<ol>
			<!--wmm:ignore-->@for (var i = 0; i < Model.People.Count; i++)
			{
				var person = Model.People[i];
				<text><li>@person</li></text>
			}<!--/wmm:ignore-->
		</ol>
	</p>
	<p>
		<!--wmm:ignore-->@if (Model.People.Count < 10)
		{
			<text>There are less than ten people.</text>
		}<!--/wmm:ignore-->
	</p>
</body>
</html>

The final rendered output is minified:

<!DOCTYPE html><html lang=en><head><title>Razor Pages Minification App</title><body><h1>Razor Pages Minification App</h1><p>RazorMinification 8/14/2017 4:24:29 PM</p><figure style=text-align:center;width:400px><img alt="Jack Burton" src=image.png><figcaption>Jack Burton (Kurt Russell)<br><em>Big Trouble in Little China</em><br>©1986 <a href="http://www.foxmovies.com/">20th Century Fox</a></figcaption></figure><p>People:<ol><li>Susan</li><li>Catalina</li><li>Diego</li><li>Bob</li></ol><p>There are less than ten people.</p>
Rendered Razor page

Additional Resources

Acknowledgements


Project Badge
TweetShare on TwitterFacebookShare on FacebookGoogle+Share on Google+LinkedinShare on Linkedin