diff --git a/src/WebOptimizer.Core/Asset.cs b/src/WebOptimizer.Core/Asset.cs
index 4c50736..0c84262 100644
--- a/src/WebOptimizer.Core/Asset.cs
+++ b/src/WebOptimizer.Core/Asset.cs
@@ -8,6 +8,7 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.TagHelpers;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
@@ -21,6 +22,11 @@ namespace WebOptimizer
{
internal class Asset : IAsset
{
+ ///
+ /// Private const extracted from .
+ /// Used to join multiple patterns for that class.
+ ///
+ internal const string PatternSeparator = ",";
private readonly ILogger _logger;
internal const string PhysicalFilesKey = "PhysicalFiles";
private readonly object _sync = new();
@@ -50,9 +56,10 @@ public Asset(string route, string contentType, IEnumerable sourceFiles,
public async Task ExecuteAsync(HttpContext context, IWebOptimizerOptions options)
{
var env = (IWebHostEnvironment)context.RequestServices.GetService(typeof(IWebHostEnvironment));
+ var cache = (IMemoryCache)context.RequestServices.GetService(typeof(IMemoryCache));
var config = new AssetContext(context, this, options);
- IEnumerable files = ExpandGlobs(this, env);
+ IEnumerable files = ExpandGlobs(this, env, cache);
DateTime lastModified = DateTime.MinValue;
@@ -84,12 +91,17 @@ public async Task ExecuteAsync(HttpContext context, IWebOptimizerOptions
return config.Content.FirstOrDefault().Value;
}
- public static IEnumerable ExpandGlobs(IAsset asset, IWebHostEnvironment env)
+ public static IEnumerable ExpandGlobs(IAsset asset, IWebHostEnvironment env, IMemoryCache cache)
{
var files = new List();
if (asset.SourceFiles.Any())
{
+ var excludePattern =
+ asset.ExcludeFiles.Count == 0
+ ? null
+ : string.Join(PatternSeparator, asset.ExcludeFiles);
+
foreach (string sourceFile in asset.SourceFiles)
{
var provider = asset.GetFileProvider(env, sourceFile, out string outSourceFile);
@@ -103,19 +115,13 @@ public static IEnumerable ExpandGlobs(IAsset asset, IWebHostEnvironment
}
else
{
- var virtualFilePaths = provider.GetAllFiles("/");
-
- var matcher = new Matcher();
- matcher.AddInclude(outSourceFile);
- matcher.AddExcludePatterns(asset.ExcludeFiles);
- PatternMatchingResult globbingResult = matcher.Match(virtualFilePaths);
-
- IEnumerable fileMatches = globbingResult.Files.Select(f => f.Path);
+ var globbingUrlBuilder = new GlobbingUrlBuilder(provider, cache, requestPathBase: /*context.Request.PathBase*/ null);
+ IEnumerable fileMatches = globbingUrlBuilder.BuildUrlList(staticUrl: null, includePattern: outSourceFile, excludePattern: excludePattern);
var sourceIsRooted = outSourceFile.StartsWith('/');
- if (sourceIsRooted)
+ if (!sourceIsRooted)
{
- fileMatches = fileMatches.Select(f => "/" + f);
+ fileMatches = fileMatches.Select(f => f.TrimStart('/'));
}
if (!fileMatches.Any())
@@ -179,7 +185,7 @@ public string GenerateCacheKey(HttpContext context, IWebOptimizerOptions options
if (!Items.ContainsKey(PhysicalFilesKey))
{
- physicalFiles = ExpandGlobs(this, env);
+ physicalFiles = ExpandGlobs(this, env, cache);
}
else
{
diff --git a/src/WebOptimizer.Core/Taghelpers/LinkTagHelper.cs b/src/WebOptimizer.Core/Taghelpers/LinkTagHelper.cs
index 1064eba..9a83e47 100644
--- a/src/WebOptimizer.Core/Taghelpers/LinkTagHelper.cs
+++ b/src/WebOptimizer.Core/Taghelpers/LinkTagHelper.cs
@@ -105,7 +105,7 @@ private void WriteIndividualTags(TagHelperOutput output, IAsset asset)
attrs.Add(attr);
}
- IEnumerable sourceFiles = Asset.ExpandGlobs(asset, HostingEnvironment);
+ IEnumerable sourceFiles = Asset.ExpandGlobs(asset, HostingEnvironment, Cache);
foreach (string file in sourceFiles)
{
diff --git a/src/WebOptimizer.Core/Taghelpers/ScriptTagHelper.cs b/src/WebOptimizer.Core/Taghelpers/ScriptTagHelper.cs
index a613127..5c4f655 100644
--- a/src/WebOptimizer.Core/Taghelpers/ScriptTagHelper.cs
+++ b/src/WebOptimizer.Core/Taghelpers/ScriptTagHelper.cs
@@ -102,7 +102,7 @@ private void WriteIndividualTags(TagHelperOutput output, IAsset asset)
attrs.Add(attr);
}
- IEnumerable sourceFiles = Asset.ExpandGlobs(asset, HostingEnvironment);
+ IEnumerable sourceFiles = Asset.ExpandGlobs(asset, HostingEnvironment, Cache);
foreach (string file in sourceFiles)
{
diff --git a/test/WebOptimizer.Core.Test/AssetTest.cs b/test/WebOptimizer.Core.Test/AssetTest.cs
index 7fdaa6e..b885671 100644
--- a/test/WebOptimizer.Core.Test/AssetTest.cs
+++ b/test/WebOptimizer.Core.Test/AssetTest.cs
@@ -1,12 +1,17 @@
-using System.IO;
-using System.Linq;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Moq;
+using WebOptimizer.Core.Test.Mocks;
using Xunit;
namespace WebOptimizer.Test
@@ -94,5 +99,314 @@ public void AssetToString()
Assert.Equal(asset.Route, asset.ToString());
}
+
+ ///
+ /// Tests that asset's correctly reads all files,
+ /// including those in nested directories, when using a glob pattern.
+ ///
+ /// Also, tests from and checks
+ /// if it works nicely with nested directories.
+ ///
+ [Fact2]
+ public async Task ExecuteAsync_GlobMatchesNestedDirectories_ReadsAllFiles()
+ {
+ var date = new DateTime(2017, 1, 1);
+ var root =
+ MockFileInfo.CreateDirectory("", date,
+ [
+ new MockFileInfo("file1.css", date, Encoding.UTF8.GetBytes("body { background-color: red; }")),
+ new MockFileInfo("file2.css", date, Encoding.UTF8.GetBytes("body { background-color: blue; }")),
+ MockFileInfo.CreateDirectory("sub", date,
+ [
+ new MockFileInfo("file3.css", date, Encoding.UTF8.GetBytes("body { background-color: green; }")),
+ ]),
+ ]);
+ var fileProvider = MockFileProvider.Create(root);
+
+ var logger = new Mock>();
+ var asset = new Asset("/all.css", "text/css", ["**/*.css"], logger.Object);
+ asset.Concatenate();
+
+ var env = new Mock();
+ env.Setup(e => e.WebRootFileProvider)
+ .Returns(fileProvider);
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ var context = new Mock();
+ context.SetupAllProperties();
+ context.Setup(c => c.RequestServices.GetService(typeof(IWebHostEnvironment)))
+ .Returns(env.Object);
+ context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache)))
+ .Returns(cache);
+ context.Setup(c => c.Response.Headers)
+ .Returns(new HeaderDictionary());
+
+ var options = new WebOptimizerOptions();
+
+ byte[] result = await asset.ExecuteAsync(context.Object, options);
+ string content = Encoding.UTF8.GetString(result);
+
+ Assert.Contains("background-color: red", content);
+ Assert.Contains("background-color: blue", content);
+ Assert.Contains("background-color: green", content);
+
+ Mock.VerifyAll(env, context);
+ }
+
+ ///
+ /// Same scenario as but
+ /// backed by a real over a temporary directory tree
+ /// instead of a mocked file provider.
+ ///
+ [Fact2]
+ public async Task ExecuteAsync_GlobMatchesNestedDirectories_PhysicalFileProvider_ReadsAllFiles()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "WebOptimizerTest_" + Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(root, "sub"));
+ File.WriteAllText(Path.Combine(root, "file1.css"), "body { background-color: red; }");
+ File.WriteAllText(Path.Combine(root, "file2.css"), "body { background-color: blue; }");
+ File.WriteAllText(Path.Combine(root, "sub", "file3.css"), "body { background-color: green; }");
+
+ var fileProvider = new PhysicalFileProvider(root);
+
+ var logger = new Mock>();
+ var asset = new Asset("/all.css", "text/css", ["**/*.css"], logger.Object);
+ asset.Concatenate();
+
+ var env = new Mock();
+ env.Setup(e => e.WebRootFileProvider)
+ .Returns(fileProvider);
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ var context = new Mock();
+ context.SetupAllProperties();
+ context.Setup(c => c.RequestServices.GetService(typeof(IWebHostEnvironment)))
+ .Returns(env.Object);
+ context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache)))
+ .Returns(cache);
+ context.Setup(c => c.Response.Headers)
+ .Returns(new HeaderDictionary());
+
+ var options = new WebOptimizerOptions();
+
+ byte[] result = await asset.ExecuteAsync(context.Object, options);
+ string content = Encoding.UTF8.GetString(result);
+
+ Assert.Contains("background-color: red", content);
+ Assert.Contains("background-color: blue", content);
+ Assert.Contains("background-color: green", content);
+
+ Mock.VerifyAll(context, env);
+ }
+ finally
+ {
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+ }
+
+ [Fact2]
+ public async Task ExecuteAsync_ExplicitSourceFiles_PreservesSourceFilesOrder()
+ {
+ var date = new DateTime(2017, 1, 1);
+ var root = MockFileInfo.CreateDirectory("", date,
+ [
+ new MockFileInfo("a.css", date, Encoding.UTF8.GetBytes(".a { color: red; }")),
+ new MockFileInfo("b.css", date, Encoding.UTF8.GetBytes(".b { color: blue; }")),
+ ]);
+ var fileProvider = MockFileProvider.Create(root);
+
+ var logger = new Mock>();
+ var asset = new Asset("/all.css", "text/css", ["b.css", "a.css"], logger.Object);
+ asset.Concatenate();
+
+ var env = new Mock();
+ env.Setup(e => e.WebRootFileProvider)
+ .Returns(fileProvider);
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ var context = new Mock();
+ context.SetupAllProperties();
+ context.Setup(c => c.RequestServices.GetService(typeof(IWebHostEnvironment)))
+ .Returns(env.Object);
+ context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache)))
+ .Returns(cache);
+ context.Setup(c => c.Response.Headers)
+ .Returns(new HeaderDictionary());
+
+ var options = new WebOptimizerOptions();
+
+ byte[] result = await asset.ExecuteAsync(context.Object, options);
+ string content = Encoding.UTF8.GetString(result);
+
+ int indexB = content.IndexOf(".b {", StringComparison.Ordinal);
+ int indexA = content.IndexOf(".a {", StringComparison.Ordinal);
+ Assert.True(indexB >= 0 && indexA >= 0, "both files must be bundled");
+ Assert.True(indexB < indexA, "b.css must come before a.css, matching SourceFiles order");
+
+ Mock.VerifyAll(env, context);
+ }
+
+ [Fact2]
+ public async Task ExecuteAsync_MultipleGlobPatterns_PreservesPatternOrder()
+ {
+ var date = new DateTime(2017, 1, 1);
+ var root = MockFileInfo.CreateDirectory("", date,
+ [
+ new MockFileInfo("root.css", date, Encoding.UTF8.GetBytes(".root { color: red; }")),
+ MockFileInfo.CreateDirectory("sub", date,
+ [
+ new MockFileInfo("nested.css", date, Encoding.UTF8.GetBytes(".nested { color: blue; }")),
+ ]),
+ ]);
+ var fileProvider = MockFileProvider.Create(root);
+
+ var logger = new Mock>();
+ var asset = new Asset("/all.css", "text/css", ["sub/*.css", "*.css"], logger.Object);
+ asset.Concatenate();
+
+ var env = new Mock();
+ env.Setup(e => e.WebRootFileProvider)
+ .Returns(fileProvider);
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ var context = new Mock();
+ context.SetupAllProperties();
+ context.Setup(c => c.RequestServices.GetService(typeof(IWebHostEnvironment)))
+ .Returns(env.Object);
+ context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache)))
+ .Returns(cache);
+ context.Setup(c => c.Response.Headers)
+ .Returns(new HeaderDictionary());
+
+ var options = new WebOptimizerOptions();
+
+ byte[] result = await asset.ExecuteAsync(context.Object, options);
+ string content = Encoding.UTF8.GetString(result);
+
+ int indexNested = content.IndexOf(".nested {", StringComparison.Ordinal);
+ int indexRoot = content.IndexOf(".root {", StringComparison.Ordinal);
+ Assert.True(indexNested >= 0 && indexRoot >= 0, "both files must be bundled");
+ Assert.True(indexNested < indexRoot, "sub/*.css pattern is listed first, so its file must come first");
+
+ Mock.VerifyAll(env, context);
+ }
+
+ [Fact2]
+ public void ExpandGlobs_ExplicitSourceFiles_PreservesSourceFilesOrder()
+ {
+ var date = new DateTime(2017, 1, 1);
+ var root = MockFileInfo.CreateDirectory("", date,
+ [
+ new MockFileInfo("a.css", date, Encoding.UTF8.GetBytes(".a { color: red; }")),
+ new MockFileInfo("b.css", date, Encoding.UTF8.GetBytes(".b { color: blue; }")),
+ ]);
+ var fileProvider = MockFileProvider.Create(root);
+
+ var logger = new Mock>();
+ var asset = new Asset("/all.css", "text/css", ["b.css", "a.css"], logger.Object);
+
+ var env = new Mock();
+ env.Setup(e => e.WebRootFileProvider)
+ .Returns(fileProvider);
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ IEnumerable files = Asset.ExpandGlobs(asset, env.Object, cache);
+
+ Assert.Equal(new[] { "b.css", "a.css" }, files);
+
+ Mock.VerifyAll(env);
+ }
+
+ [Fact2]
+ public void ExpandGlobs_MultipleGlobPatterns_PreservesPatternOrder()
+ {
+ var date = new DateTime(2017, 1, 1);
+ var root = MockFileInfo.CreateDirectory("", date,
+ [
+ new MockFileInfo("root.css", date, Encoding.UTF8.GetBytes(".root { color: red; }")),
+ MockFileInfo.CreateDirectory("sub", date,
+ [
+ new MockFileInfo("nested.css", date, Encoding.UTF8.GetBytes(".nested { color: blue; }")),
+ ]),
+ ]);
+ var fileProvider = MockFileProvider.Create(root);
+
+ var logger = new Mock>();
+ var asset = new Asset("/all.css", "text/css", ["sub/*.css", "*.css"], logger.Object);
+
+ var env = new Mock();
+ env.Setup(e => e.WebRootFileProvider)
+ .Returns(fileProvider);
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ IEnumerable files = Asset.ExpandGlobs(asset, env.Object, cache);
+
+ Assert.Equal(new[] { "sub/nested.css", "root.css" }, files);
+
+ Mock.VerifyAll(env);
+ }
+
+ ///
+ /// maps every entry to a group of
+ /// matched files and keeps those groups in entry order. The order *within* a glob group is an
+ /// implementation detail and is not asserted.
+ ///
+ [Fact2]
+ public void ExpandGlobs_MixedExplicitAndGlobPatterns_KeepsEntryGroupOrder()
+ {
+ var date = new DateTime(2017, 1, 1);
+ var root = MockFileInfo.CreateDirectory("", date,
+ [
+ new MockFileInfo("alpha.css", date, Encoding.UTF8.GetBytes(".alpha { color: red; }")),
+ new MockFileInfo("zeta.css", date, Encoding.UTF8.GetBytes(".zeta { color: blue; }")),
+ new MockFileInfo("style-a.css", date, Encoding.UTF8.GetBytes(".style-a { color: #FF0000; }")),
+ new MockFileInfo("style-b.css", date, Encoding.UTF8.GetBytes(".style-b { color: #00FF00; }")),
+ MockFileInfo.CreateDirectory("sub", date,
+ [
+ new MockFileInfo("inner.css", date, Encoding.UTF8.GetBytes(".inner { color: green; }")),
+ ]),
+ ]);
+ var fileProvider = MockFileProvider.Create(root);
+
+ var logger = new Mock>();
+ var asset = new Asset("/all.css", "text/css", ["zeta.css", "sub/*.css", "style-*.css", "alpha.css"], logger.Object);
+
+ var env = new Mock();
+ env.Setup(e => e.WebRootFileProvider)
+ .Returns(fileProvider);
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ List files = [.. Asset.ExpandGlobs(asset, env.Object, cache)];
+ // zeta.css, sub/inner.css, (style-a.css, style-b.css - any order), alpha.css
+
+ Assert.Equal(5, files.Count);
+
+ int indexZeta = files.IndexOf("zeta.css");
+ int indexInner = files.IndexOf("sub/inner.css");
+ int indexStyleA = files.IndexOf("style-a.css");
+ int indexStyleB = files.IndexOf("style-b.css");
+ int indexAlpha = files.IndexOf("alpha.css");
+
+ Assert.True(indexZeta < indexInner, "zeta.css group precedes sub/*.css group");
+ Assert.True(indexInner < indexStyleA && indexInner < indexStyleB,
+ "sub/*.css group precedes style-*.css group");
+ Assert.True(indexStyleA < indexAlpha && indexStyleB < indexAlpha,
+ "style-*.css group precedes alpha.css group");
+
+ Mock.VerifyAll(env);
+ }
}
}
\ No newline at end of file
diff --git a/test/WebOptimizer.Core.Test/Mocks/MockChangeToken.cs b/test/WebOptimizer.Core.Test/Mocks/MockChangeToken.cs
new file mode 100644
index 0000000..b783554
--- /dev/null
+++ b/test/WebOptimizer.Core.Test/Mocks/MockChangeToken.cs
@@ -0,0 +1,26 @@
+using System;
+using Microsoft.Extensions.Primitives;
+
+namespace WebOptimizer.Core.Test.Mocks
+{
+ public class MockChangeToken : IChangeToken
+ {
+ public bool ActiveChangeCallbacks => false;
+
+ public bool HasChanged => false;
+
+ public IDisposable RegisterChangeCallback(Action