diff --git a/src/WebOptimizer.Core/AssetPipeline.cs b/src/WebOptimizer.Core/AssetPipeline.cs index 4329b7d..dfae67b 100644 --- a/src/WebOptimizer.Core/AssetPipeline.cs +++ b/src/WebOptimizer.Core/AssetPipeline.cs @@ -119,6 +119,17 @@ public IAsset AddBundle(string route, string contentType, params string[] source return asset; } + + public IAsset AddAsset(string route, string contentType) + { + route = NormalizeRoute(route); + + IAsset asset = new Asset(route, contentType, this, new string[0]); + _assets.TryAdd(route, asset); + + return asset; + } + public IEnumerable AddFiles(string contentType, params string[] sourceFiles) { if (string.IsNullOrEmpty(contentType)) diff --git a/src/WebOptimizer.Core/Extensions/ApplicationBuilderExtensions.cs b/src/WebOptimizer.Core/Extensions/ApplicationBuilderExtensions.cs index ebc7707..0aa98db 100644 --- a/src/WebOptimizer.Core/Extensions/ApplicationBuilderExtensions.cs +++ b/src/WebOptimizer.Core/Extensions/ApplicationBuilderExtensions.cs @@ -36,6 +36,7 @@ public static IApplicationBuilder UseWebOptimizer(this IApplicationBuilder app) if (app.ApplicationServices.GetService(typeof(IAssetPipeline)) == null) { + // TODO: This error message is incorrect for Program.cs in .Net 8.0 - ConfigureServices() is retired and the call is more like services.AddWebOptimizer() now. string msg = "Unable to find the required services. Please add all the required services by calling 'IServiceCollection.AddWebOptimizer' inside the call to 'ConfigureServices(...)' in the application startup code."; throw new InvalidOperationException(msg); } diff --git a/src/WebOptimizer.Core/IAssetPipeline.cs b/src/WebOptimizer.Core/IAssetPipeline.cs index 199b74e..e4923bd 100644 --- a/src/WebOptimizer.Core/IAssetPipeline.cs +++ b/src/WebOptimizer.Core/IAssetPipeline.cs @@ -31,6 +31,17 @@ public interface IAssetPipeline /// A list of relative file names of the sources to optimize. IAsset AddBundle(string route, string contentType, params string[] sourceFiles); + /// + /// Add a generalized Asset, that has just a Route and a ContentType + /// + /// Typically used to fill in with content details later by Processors + /// + /// Used by AddJavaScriptBundle() to emit Source Maps on their own route + /// + /// The route that should cause the pipeline to respond with this Asset + /// Content-Type of the response + IAsset AddAsset(string route, string contentType); + /// /// Adds an array of files to the optimization pipeline. /// diff --git a/src/WebOptimizer.Core/Processors/ItemContentEmitter.cs b/src/WebOptimizer.Core/Processors/ItemContentEmitter.cs new file mode 100644 index 0000000..bad7059 --- /dev/null +++ b/src/WebOptimizer.Core/Processors/ItemContentEmitter.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using WebOptimizer; +using WebOptimizer.Processors; + +namespace WebOptimizer.Processors +{ + internal class ItemContentEmitter : Processor + { + public override Task ExecuteAsync(IAssetContext context) + { + var asset = context.Asset; + var items = asset.Items; + if (!items.ContainsKey("Content")) + return Task.CompletedTask; + + context.Content = new Dictionary + { + { "Content", ((string)items["Content"]).AsByteArray() } + }; + + return Task.CompletedTask; + } + } +} + + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class AssetPipelineExtensions + { + /// + /// Changes the Asset to only emit to Response what is stored in + /// asset.Items["Content"] + /// and nothing else + /// + /// Useful for Generated Content + /// + /// Used by JavaScriptMinifier.AddJavaScriptBundle to emit sourcemaps into a separate Asset + /// when generating minified code + /// + /// + /// + public static IAsset UseItemContent(this IAsset asset) + { + asset.Processors.Add(new ItemContentEmitter()); + return asset; + } + + /// + /// Changes the Asset to only emit to Response what is stored in + /// asset.Items["Content"] + /// and nothing else + /// + /// Useful for Generated Content + /// + /// Used by JavaScriptMinifier.AddJavaScriptBundle to emit sourcemaps into a separate Asset + /// when generating minified code + /// + public static IEnumerable UseItemContent(this IEnumerable assets) + { + return assets.AddProcessor(asset => asset.UseItemContent()); + } + } +} diff --git a/src/WebOptimizer.Core/Processors/JavaScriptMinifier.cs b/src/WebOptimizer.Core/Processors/JavaScriptMinifier.cs index c8463cd..0ff7aad 100644 --- a/src/WebOptimizer.Core/Processors/JavaScriptMinifier.cs +++ b/src/WebOptimizer.Core/Processors/JavaScriptMinifier.cs @@ -1,23 +1,27 @@ using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; using System.Threading.Tasks; using NUglify; using NUglify.JavaScript; using WebOptimizer; +using WebOptimizer.Processors; namespace WebOptimizer { internal class JavaScriptMinifier : Processor { - public JavaScriptMinifier(CodeSettings settings) + public JavaScriptMinifier(JsSettings settings) { Settings = settings; } - public CodeSettings Settings { get; set; } + public JsSettings Settings { get; set; } public override Task ExecuteAsync(IAssetContext config) { - if (!Settings.MinifyCode) return Task.CompletedTask; + if (!Settings.CodeSettings.MinifyCode) return Task.CompletedTask; var content = new Dictionary(); foreach (string key in config.Content.Keys) @@ -30,14 +34,52 @@ public override Task ExecuteAsync(IAssetContext config) string input = config.Content[key].AsString(); string minified; + try { - UglifyResult result = Uglify.Js(input, Settings); + UglifyResult result; + string sourceMapContent = null; + + // If .AddJavascriptBundle setup the SourceMap Asset, it will be assigned here, we need to fill it + var sourceMapAsset = Settings.PipelineSourceMap; + if (sourceMapAsset != null) + { + // Setup the side-effects writing of the SourceMap file + var sb = new StringBuilder(); + using (var sw = new StringWriter(sb)) + { + using (var sourceMap = new V3SourceMap(sw)) + { + // Causes the side-effect writing of the SourceMap to our StringWriter... + Settings.CodeSettings.SymbolsMap = sourceMap; + sourceMap.MakePathsRelative = false; + sourceMap.StartPackage(config.Asset.Route, sourceMapAsset.Route); + + result = Uglify.Js(input, Settings.CodeSettings); + } + // These Dispose steps cause the actual flush of the content to the StringBuilder + } + sourceMapContent = sb.ToString(); + } + else + { + result = Uglify.Js(input, Settings.CodeSettings); + } + minified = result.Code; + if (result.HasErrors) { minified = $"/* {string.Join("\r\n", result.Errors)} */\r\n" + input; } + else + { + if (sourceMapContent != null) + { + // Successful minification, and source map generation succeeded, write out to its separate Asset/Route + sourceMapAsset.Items["Content"] = sourceMapContent; + } + } } catch { @@ -75,7 +117,7 @@ public static IEnumerable MinifyJsFiles(this IAssetPipeline pipeline) /// public static IEnumerable MinifyJsFiles(this IAssetPipeline pipeline, CodeSettings settings) { - return pipeline.MinifyJsFiles(settings, "**/*.js"); + return pipeline.MinifyJsFiles(new JsSettings(settings), "**/*.js"); } /// @@ -83,13 +125,13 @@ public static IEnumerable MinifyJsFiles(this IAssetPipeline pipeline, Co /// public static IEnumerable MinifyJsFiles(this IAssetPipeline pipeline, params string[] sourceFiles) { - return pipeline.MinifyJsFiles(new CodeSettings(), sourceFiles); + return pipeline.MinifyJsFiles(new JsSettings(), sourceFiles); } /// - /// Minifies tje specified .js files. + /// Minifies the specified .js files. /// - public static IEnumerable MinifyJsFiles(this IAssetPipeline pipeline, CodeSettings settings, params string[] sourceFiles) + public static IEnumerable MinifyJsFiles(this IAssetPipeline pipeline, JsSettings settings, params string[] sourceFiles) { return pipeline.AddFiles("text/javascript; charset=UTF-8", sourceFiles) .AddResponseHeader("X-Content-Type-Options", "nosniff") @@ -101,19 +143,33 @@ public static IEnumerable MinifyJsFiles(this IAssetPipeline pipeline, Co /// public static IAsset AddJavaScriptBundle(this IAssetPipeline pipeline, string route, params string[] sourceFiles) { - return pipeline.AddJavaScriptBundle(route, new CodeSettings(), sourceFiles); + return pipeline.AddJavaScriptBundle(route, new JsSettings(), sourceFiles); } /// /// Creates a JavaScript bundle on the specified route and minifies the output. /// - public static IAsset AddJavaScriptBundle(this IAssetPipeline pipeline, string route, CodeSettings settings, params string[] sourceFiles) + public static IAsset AddJavaScriptBundle(this IAssetPipeline pipeline, string route, JsSettings settings, params string[] sourceFiles) { - return pipeline.AddBundle(route, "text/javascript; charset=UTF-8", sourceFiles) - .EnforceFileExtensions(".js", ".jsx", ".es5", ".es6") - .Concatenate() - .AddResponseHeader("X-Content-Type-Options", "nosniff") - .MinifyJavaScript(settings); + var bundleAsset = pipeline.AddBundle(route, "text/javascript; charset=UTF-8", sourceFiles) + .EnforceFileExtensions(".js", ".jsx", ".es5", ".es6") + .Concatenate() + .AddResponseHeader("X-Content-Type-Options", "nosniff") + .MinifyJavaScript(settings); + + if (settings.GenerateSourceMap) + { + // A simple config flag saying to generate a SourceMap - the legwork is on the framework + // Nuglify returns minified Javascript while generating a SourceMap as a side effect, like it or not, and + // It's not possible to ask for a map until the first time the minified code is delivered (since the map is in the comments of the min bundle), + // so we add a null route/Asset to the pipeline for now, and we'll fill it in later on first request of the bundle + string mapRoute = route.Replace(".js", ".map.js"); + var sourceMapAsset = pipeline.AddAsset(mapRoute, "application/json") + .UseItemContent(); + settings.PipelineSourceMap = sourceMapAsset; + } + + return bundleAsset; } /// @@ -121,13 +177,13 @@ public static IAsset AddJavaScriptBundle(this IAssetPipeline pipeline, string ro /// public static IAsset MinifyJavaScript(this IAsset asset) { - return asset.MinifyJavaScript(new CodeSettings()); + return asset.MinifyJavaScript(new JsSettings()); } /// /// Runs the JavaScript minifier on the content. /// - public static IAsset MinifyJavaScript(this IAsset asset, CodeSettings settings) + public static IAsset MinifyJavaScript(this IAsset asset, JsSettings settings) { var minifier = new JavaScriptMinifier(settings); asset.Processors.Add(minifier); @@ -140,13 +196,13 @@ public static IAsset MinifyJavaScript(this IAsset asset, CodeSettings settings) /// public static IEnumerable MinifyJavaScript(this IEnumerable assets) { - return assets.MinifyJavaScript(new CodeSettings()); + return assets.MinifyJavaScript(new JsSettings()); } /// /// Runs the JavaScript minifier on the content. /// - public static IEnumerable MinifyJavaScript(this IEnumerable assets, CodeSettings settings) + public static IEnumerable MinifyJavaScript(this IEnumerable assets, JsSettings settings) { return assets.AddProcessor(asset => asset.MinifyJavaScript(settings)); } diff --git a/src/WebOptimizer.Core/Processors/JsBundleSettings.cs b/src/WebOptimizer.Core/Processors/JsBundleSettings.cs new file mode 100644 index 0000000..e650a88 --- /dev/null +++ b/src/WebOptimizer.Core/Processors/JsBundleSettings.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Text; + + +namespace WebOptimizer.Processors +{ + public class JsSettings + { + /// + /// Defaults to false. + /// Whether to generate source maps, allowing bundled code to be debugged using original source in places like Chrome Dev Tools. + /// Respects .SymbolsMap on base class, CodeSettings; this setting is ignored (treated as false) if caller sets .SymbolsMap + /// + public bool GenerateSourceMap { get; set; } + + /// + /// Set by the framework if GenerateSourceMap is true + /// Helps a given Bundle Asset identify its SourceMap Asset to write to. + /// + public IAsset PipelineSourceMap { get; set; } + + /// + /// NUglify is the underlying minifier for WebOptimizer. + /// It's derived from Microsoft's AjaxMin. + /// + public NUglify.JavaScript.CodeSettings CodeSettings { get; set; } + + + + public JsSettings() + { + CodeSettings = new NUglify.JavaScript.CodeSettings(); + } + public JsSettings(NUglify.JavaScript.CodeSettings nuglifyCodeSettings) + { + CodeSettings = nuglifyCodeSettings; + } + } +} diff --git a/src/WebOptimizer.Core/TagHelpersDynamic/Helpers.cs b/src/WebOptimizer.Core/TagHelpersDynamic/Helpers.cs index 9fea283..783c829 100644 --- a/src/WebOptimizer.Core/TagHelpersDynamic/Helpers.cs +++ b/src/WebOptimizer.Core/TagHelpersDynamic/Helpers.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.DependencyInjection; +using WebOptimizer.Processors; namespace WebOptimizer.TagHelpersDynamic { @@ -91,7 +92,7 @@ internal static IAsset CreateJsAsset(IAssetPipeline pipeline, string key) if (settings.Minify) { - asset = asset.MinifyJavaScript(settings.CodeSettings); + asset = asset.MinifyJavaScript(new JsSettings(settings.CodeSettings)); } return asset; diff --git a/src/WebOptimizer.Core/WebOptimizer.Core.csproj b/src/WebOptimizer.Core/WebOptimizer.Core.csproj index fa80585..87a9ac7 100644 --- a/src/WebOptimizer.Core/WebOptimizer.Core.csproj +++ b/src/WebOptimizer.Core/WebOptimizer.Core.csproj @@ -43,6 +43,6 @@ true - + diff --git a/test/WebOptimizer.Core.Test/Processors/JavaScriptMinifierTest.cs b/test/WebOptimizer.Core.Test/Processors/JavaScriptMinifierTest.cs index 53c9dbc..bef562c 100644 --- a/test/WebOptimizer.Core.Test/Processors/JavaScriptMinifierTest.cs +++ b/test/WebOptimizer.Core.Test/Processors/JavaScriptMinifierTest.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Moq; using NUglify.JavaScript; +using WebOptimizer.Processors; using Xunit; namespace WebOptimizer.Test.Processors @@ -14,7 +15,7 @@ public class JavaScriptMinifierTest [Fact2] public async Task MinifyJs_DefaultSettings_Success() { - var minifier = new JavaScriptMinifier(new CodeSettings()); + var minifier = new JavaScriptMinifier(new JsSettings()); var context = new Mock().SetupAllProperties(); context.Object.Content = new Dictionary { { "", "var i = 0;".AsByteArray() } }; var options = new Mock(); @@ -33,7 +34,7 @@ public async Task MinifyJs_DefaultSettings_Success() [InlineData("\r\n \t \r \n")] public async Task MinifyJs_EmptyContent_Success(string input) { - var minifier = new JavaScriptMinifier(new CodeSettings()); + var minifier = new JavaScriptMinifier(new JsSettings()); var context = new Mock().SetupAllProperties(); context.Object.Content = new Dictionary { { "", input.AsByteArray() } }; var options = new Mock(); @@ -47,7 +48,7 @@ public async Task MinifyJs_EmptyContent_Success(string input) [Fact2] public async Task MinifyJs_CustomSettings_Success() { - var settings = new CodeSettings { TermSemicolons = true}; + var settings = new JsSettings(new CodeSettings { TermSemicolons = true}); var minifier = new JavaScriptMinifier(settings); var context = new Mock().SetupAllProperties(); context.Object.Content = new Dictionary { { "", "var i = 0;".AsByteArray() } }; @@ -87,7 +88,7 @@ public void AddJsBundle_DefaultSettings_SuccessRelative() [Fact2] public void AddJsBundle_CustomSettings_Success() { - var settings = new CodeSettings(); + var settings = new JsSettings(); var pipeline = new AssetPipeline(); var asset = pipeline.AddJavaScriptBundle("/foo.js", settings, "file1.js", "file2.js");