Skip to content

Instantly share code, notes, and snippets.

@guardrex
Last active June 28, 2016 05:29
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save guardrex/d3c08c8c337f646b8b60 to your computer and use it in GitHub Desktop.
Save guardrex/d3c08c8c337f646b8b60 to your computer and use it in GitHub Desktop.
Razor Markup Minfier
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Razor.Directives;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Chunks;
using Microsoft.AspNetCore.Razor.CodeGenerators;
using Microsoft.AspNetCore.Razor.Parser;
using Microsoft.AspNetCore.Razor.CodeGenerators.Visitors;
using Microsoft.AspNetCore.Razor.Compilation.TagHelpers;
using System.Text;
using System.Text.RegularExpressions;
namespace CustomMvcRazorHost
{
public class MinifyingMvcRazorHost_ChunkVisitor : IMvcRazorHost
{
private readonly MvcRazorHost _host;
public static bool HasOnlySeenOpenBracket = false;
public string DefaultNamespace => _host.DefaultNamespace;
public string MainClassNamePrefix => _host.DefaultClassName;
private ChunkInheritanceUtility _chunkInheritanceUtility;
private readonly IChunkTreeCache _chunkTreeCache;
private const string ModelExpressionProviderProperty = "ModelExpressionProvider";
private const string ViewDataProperty = "ViewData";
public MinifyingMvcRazorHost_ChunkVisitor(IChunkTreeCache chunkTreeCache, ITagHelperDescriptorResolver descriptorResolver)
{
_chunkTreeCache = chunkTreeCache;
_host = new MvcRazorHost(chunkTreeCache, descriptorResolver);
}
public GeneratorResults GenerateCode(string rootRelativePath, Stream inputStream)
{
var className = MainClassNamePrefix + ParserHelpers.SanitizeClassName(rootRelativePath);
var engine = new CustomRazorTemplateEngine(this, _host);
return engine.GenerateCode(inputStream, className, DefaultNamespace, rootRelativePath);
}
public CodeGenerator DecorateCodeGenerator(CodeGenerator incomingGenerator, CodeGeneratorContext context)
{
var inheritedChunkTrees = GetInheritedChunkTrees(context.SourceFile);
ChunkInheritanceUtility.MergeInheritedChunkTrees(
context.ChunkTreeBuilder.Root,
inheritedChunkTrees,
_host.DefaultModel);
return new CustomCSharpCodeGenerator(
context,
_host.DefaultModel,
_host.InjectAttribute,
new GeneratedTagHelperAttributeContext
{
ModelExpressionTypeName = _host.ModelExpressionType,
CreateModelExpressionMethodName = _host.CreateModelExpressionMethod,
ModelExpressionProviderPropertyName = ModelExpressionProviderProperty,
ViewDataPropertyName = ViewDataProperty
});
}
public virtual string ModelExpressionProvider
{
get { return ModelExpressionProviderProperty; }
}
public virtual string ViewDataPropertyName
{
get { return ViewDataProperty; }
}
internal ChunkInheritanceUtility ChunkInheritanceUtility
{
get
{
if (_chunkInheritanceUtility == null)
{
_chunkInheritanceUtility = new ChunkInheritanceUtility(_host, _chunkTreeCache, _host.DefaultInheritedChunks);
}
return _chunkInheritanceUtility;
}
set
{
_chunkInheritanceUtility = value;
}
}
private IReadOnlyList<ChunkTree> GetInheritedChunkTrees(string sourceFileName)
{
var inheritedChunkTrees = _host.GetInheritedChunkTreeResults(sourceFileName)
.Select(result => result.ChunkTree)
.ToList();
return inheritedChunkTrees;
}
}
public class CustomRazorTemplateEngine : RazorTemplateEngine
{
private MinifyingMvcRazorHost_ChunkVisitor _myHost;
public CustomRazorTemplateEngine(MinifyingMvcRazorHost_ChunkVisitor myHost, MvcRazorHost host) : base(host)
{
_myHost = myHost;
}
protected override CodeGenerator CreateCodeGenerator(CodeGeneratorContext context)
{
return _myHost.DecorateCodeGenerator(Host.CodeLanguage.CreateCodeGenerator(context), context);
}
}
public class CustomCSharpCodeGenerator : MvcCSharpCodeGenerator
{
private GeneratedTagHelperAttributeContext _tagHelperAttributeContext;
public CustomCSharpCodeGenerator(CodeGeneratorContext context, string defaultModel, string injectAttribute, GeneratedTagHelperAttributeContext tagHelperAttributeContext) : base(context, defaultModel, injectAttribute, tagHelperAttributeContext)
{
_tagHelperAttributeContext = tagHelperAttributeContext;
}
protected override CSharpCodeVisitor CreateCSharpCodeVisitor(CSharpCodeWriter writer, CodeGeneratorContext context)
{
var csharpCodeVisitor = new CustomCSharpCodeVisitor(writer, context);
csharpCodeVisitor.TagHelperRenderer.AttributeValueCodeRenderer = new MvcTagHelperAttributeValueCodeRenderer(_tagHelperAttributeContext);
return csharpCodeVisitor;
}
}
public class CustomCSharpCodeVisitor : CSharpCodeVisitor
{
public CustomCSharpCodeVisitor(CSharpCodeWriter writer, CodeGeneratorContext context) : base(writer, context)
{ }
private bool _outsideOfElement = true;
private bool _comingOffHyperlink = false;
private StringBuilder chunkStringBuilder = new StringBuilder();
private StringBuilder chunkCharactersStringBuilder = new StringBuilder();
private const int MaxStringLiteralLength = 1024;
protected override void Visit(ParentLiteralChunk chunk)
{
if (Context.Host.DesignTimeMode)
{
// Skip generating the chunk if we're in design time or if the chunk is empty.
return;
}
var text = chunk.GetText();
if (Context.Host.EnableInstrumentation)
{
var start = chunk.Start.AbsoluteIndex;
Writer.WriteStartInstrumentationContext(Context, start, text.Length, isLiteral: true);
}
RenderStartWriteLiteral(MinifyChunk(text));
if (Context.Host.EnableInstrumentation)
{
Writer.WriteEndInstrumentationContext(Context);
}
}
protected override void Visit(LiteralChunk chunk)
{
if (Context.Host.DesignTimeMode || string.IsNullOrEmpty(chunk.Text))
{
// Skip generating the chunk if we're in design time or if the chunk is empty.
return;
}
if (Context.Host.EnableInstrumentation)
{
Writer.WriteStartInstrumentationContext(Context, chunk.Association, isLiteral: true);
}
RenderStartWriteLiteral(MinifyChunk(chunk.Text));
if (Context.Host.EnableInstrumentation)
{
Writer.WriteEndInstrumentationContext(Context);
}
}
protected override void Visit(TagHelperChunk chunk)
{
if (chunk.TagName == "a")
{
Writer.Write(@"WriteLiteral("" "");");
TagHelperRenderer.RenderTagHelper(chunk);
_comingOffHyperlink = true;
}
else
{
TagHelperRenderer.RenderTagHelper(chunk);
}
}
private void RenderStartWriteLiteral(string text)
{
var charactersRendered = 0;
// Render the string in pieces to avoid Roslyn OOM exceptions at compile time:
// https://github.com/aspnet/External/issues/54
while (charactersRendered < text.Length)
{
if (Context.ExpressionRenderingMode == ExpressionRenderingMode.WriteToOutput)
{
if (!string.IsNullOrEmpty(Context.TargetWriterName))
{
Writer.WriteStartMethodInvocation(Context.Host.GeneratedClassContext.WriteLiteralToMethodName)
.Write(Context.TargetWriterName)
.WriteParameterSeparator();
}
else
{
Writer.WriteStartMethodInvocation(Context.Host.GeneratedClassContext.WriteLiteralMethodName);
}
}
string textToRender;
if (text.Length <= MaxStringLiteralLength)
{
textToRender = text;
}
else
{
int charRemaining = text.Length - charactersRendered;
if (charRemaining < MaxStringLiteralLength)
{
textToRender = text.Substring(charactersRendered, charRemaining);
}
else
{
textToRender = text.Substring(charactersRendered, MaxStringLiteralLength);
}
}
Writer.WriteStringLiteral(textToRender);
charactersRendered += textToRender.Length;
if (Context.ExpressionRenderingMode == ExpressionRenderingMode.WriteToOutput)
{
Writer.WriteEndMethodInvocation();
}
}
}
private string MinifyChunk(string chunkText)
{
chunkStringBuilder.Clear();
chunkCharactersStringBuilder.Clear();
foreach (char c in chunkText)
{
if (_outsideOfElement)
{
if (!c.Equals('<'))
{
// Collect this inter-element character for processing later
chunkCharactersStringBuilder.Append(c);
}
else
{
// We just came to the end of an inter-element sequence of characters ... process these characters now
_outsideOfElement = false;
chunkStringBuilder.Append(MinifyCharacters(chunkCharactersStringBuilder.ToString()) + c);
chunkCharactersStringBuilder.Clear();
}
}
else
{
// Accumulate this element character to the output chunk
chunkStringBuilder.Append(c);
if (c.Equals('>'))
{
// Time to start collecting and processing characters again
_outsideOfElement = true;
}
}
}
// Minify and add any leftover characters b/c we didn't reach the end of an inter-element sequence in this chunk
chunkStringBuilder.Append(MinifyCharacters(chunkCharactersStringBuilder.ToString()));
// Look around here and see if we can add a space back around inline elements that are inside the chunk
// This assumes the inline element is next to the text IN THE SAME chunk. [That might be a bad assumption!]
if (_comingOffHyperlink)
{
_comingOffHyperlink = false;
var chunkStringBuilderVal = chunkStringBuilder.ToString();
if (chunkStringBuilderVal.Length > 0 && Regex.IsMatch(chunkStringBuilderVal.Substring(0, 1), "[A-Za-z0-9]"))
{
return " " + AddSpaceBack(chunkStringBuilderVal);
}
}
return AddSpaceBack(chunkStringBuilder.ToString());
}
private string MinifyCharacters(string inputString)
{
return inputString.Replace("\t", string.Empty).Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(' ');
}
private string AddSpaceBack(string inputString)
{
return Regex.Replace(inputString, "((?<=\\S)(?=(<a|<b|<big|<i|<small|<tt|<abbr|<acronym|<cite|<code|<dfn|<em|<kbd|<strong|<samp|<time|<var|<bdo|<br|<img|<map|<object|<q|<span|<sub|<sup|<button|<input|<label|<select|<textarea)))|((?<=(/a>|/b>|/big>|/i>|/small>|/tt>|/abbr>|/acronym>|/cite>|/code>|/dfn>|/em>|/kbd>|/strong>|/samp>|/time>|/var>|/bdo>|/br>|/map>|/object>|/q>|/span>|/sub>|/sup>|/button>|/input>|/label>|/select>|/textarea>))(?=[A-Za-z0-9]))", " ");
}
}
}
@Anderman
Copy link

I think you don't remove html comment like // or <!-- --> But you remove \r and '\n`
now if you have

\\ comment
  javascriptcode
you get 
\\ comment javascriptcode

@guardrex
Copy link
Author

@Anderman Good point. I just ignore comments. This rapidly becomes a rather difficult problem trying to get all of the logic in there to do what the best minifiers do (e.g., ajaxmin, Gulp, etc.).

It's made worse by the fact that the Razor engine runs on these chunks, and I'm not clear on what to expect logic-wise on where/how chunks are broken up. I'm also not enjoying the fact that you have to look across chunks to get things minified well (e.g., the hyperlink situation, were you'll have a space before and/or after the hyperlink based on what the other chunks hold around it ... and no simple way to dig them up and look at them on the fly in the TagHelper visitor.

I would be happier if we just had the entire rendered markup document to work with right after the compile when all of the chunks are back together but before it goes into the Razor cache; or if they are written as chunks into the cache, if we could hit these docs once right after they are complete in the cache. It would make this a lot easier.

@guardrex
Copy link
Author

guardrex commented Dec 5, 2015

@Anderman I updated for RC2 by changing context.ChunkTreeBuilder.ChunkTree to context.ChunkTreeBuilder.Root, and that was the only compiler complaint; but now, it's not minifying any longer. There are no exceptions ... but also no minification is happening.

@guardrex
Copy link
Author

guardrex commented Dec 8, 2015

This has been updated with the latest bits needed to work with the latest Razor packages. It definitely works with "Microsoft.AspNet.Mvc.Razor": "6.0.0-rc2-16579" and later.

@Anderman
Copy link

Anderman commented Dec 8, 2015

Cool, I think, I missed your comment of last week. I am still on RC1

@Anderman
Copy link

Anderman commented Dec 8, 2015

@guardrex, Strange I got no mail from Gists

@guardrex
Copy link
Author

guardrex commented Dec 9, 2015

@Anderman Me neither. Bug? I'll file an issue with Github on it.

UPDATE: I made more changes to this, and it's working even better than before. I added the literal chunk visitor back and modified the Regex expression to improve it a bit.

@guardrex
Copy link
Author

guardrex commented Dec 9, 2015

Notes (12/9): This is finally working pretty darn good. It's not perfect. If you see a break in your markup that the code isn't catching (and you have a touch of OCD about it ... as I often do), then go into your markup and manually remove the newline there. If you find that it's taking out a space from your markup that you must have there for proper rendering, you can go into the markup and right where you want the space instead of putting a real space there drop in a non-breaking space &nbsp; ... this code will just go right past that and the browser will render it just fine.

AFAIK, you can use this as a singleton:

services.AddSingleton<IMvcRazorHost, CustomMvcRazorHost>();

[EDIT]

I still haven't gotten around to making it handle comments. I'll see if I have some time to take it a step further there.

Also, keep in mind that this minifier will not minify style elements or script elements (i.e., it's not going to compress styles in the markup and JavaScript/jQuery scripts). It assumes that you've already done that and that your markup starts in this format. In this example, I show how some styles in the markup have already been minfied (by Gulp into a .css file that I cut-and-paste out of into this file). I show how some JavaScript has also been compressed externally to a .js file (by Gulp as well) and dropped into the bottom of this page. The minifier code above just skips all of this pre-comressed content.

... SOME HEAD TAGS BEFORE THE STYLES ....
        <link rel="image_src" href="http://xxxxxx.vo.msecnd.net/www-xxxxxxx-com/logo.png"/>
        <style type="text/css">footer nav,h1{margin-bottom:15px}aside,h1{font-style:italic}h2,h3{font-weight:700}*{margin:0;padding:0}html{overflow-y:scroll}body{font-size:13px;color:#535353;font-family:Arial,Helvetica,sans-serif;max-width:1000px;margin:0 auto} ... MORE STYLE CONTENT ...</style>
    </head>
<body>
... MARKUP CONTINUES ... THEN AT THE BOTTOM ...
        &copy;@DateTime.Now.Year&nbsp;My Corporation
    </footer>
    <script type="text/javascript">function setSelectedIndex(sx,v){for(var i=0;i<sx.options.length;i++)if(sx.options[i].value==v){sx.options[i].selected=!0;return}}function inArray(arr,item){for(var p=0;p<arr.length;p++)if(item==arr[p])return!0;return!1}function SetSlideBoxPadding(xbox){document.getElementById("slide-box-content-"+xbox).style.paddingTop=(x-document.getElementById("slide-box-inner-content-"+xbox).clientHeight)/2+"px"} ... MORE SCRIPT CONTENT ...</script>
</body>
</html>

@guardrex
Copy link
Author

This has been updated to only add the space back to the backside of the closing tag if the next character is within [A-Za-z0-9]. I used to have \\S there, but that was being too greedy and adding the space back when a mark of punctuation came after the closing tag.

An outstanding issue is with links ... it's still adding a space back after a link and before a mark of punctuation. This occurs because adding a space around a link is actually handled by the TagHelperChunk visitor, and I'll need to get some kind of logic in there to assess the next character behind the link before injecting that second Writer.Write(@"WriteLiteral("" "");"); there.

@guardrex
Copy link
Author

Ok, I updated it to keep track of when it's coming off a hyperlink from the TagHelperChunk visitor. When it gets to the next chunk after a hyperlink, MinifyChunk will check the first character. If that character is within [A-Za-z0-9], it will add a space after the hyperlink. Otherwise (e.g., a punctuation mark is there), it will not add a space at the start of that chunk.

@guardrex
Copy link
Author

This has been updated to work with the latest updates by @NTaylorMullen to the Razor Engine. aspnet/Razor@4db32ec

@guardrex
Copy link
Author

guardrex commented Apr 3, 2016

This has been updated to work with the latest updates that included adding the ITagHelperDescriptorResolver to the engine ctor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment