From 6e845f0171fe798c166efe1982d50450e43c27e9 Mon Sep 17 00:00:00 2001 From: damianedwards Date: Mon, 16 Feb 2015 18:16:52 -0800 Subject: [PATCH] Add globbing support to the LinkTagHelper: - #1581 --- .../Views/Shared/_Layout.cshtml | 7 +- samples/TagHelperSample.Web/appRoot.css | 8 + samples/TagHelperSample.Web/project.json | 2 +- samples/TagHelperSample.Web/wwwroot/blank.css | 0 .../wwwroot/css/sub/blank.css | 1 + .../Internal/AttributeMatcher.cs | 145 ++++++++ .../Internal/FileProviderGlobbingDirectory.cs | 131 ++++++++ .../Internal/FileProviderGlobbingFile.cs | 26 ++ .../Internal/GlobbingUrlBuilder.cs | 153 +++++++++ .../Internal/JavaScriptResources.cs | 62 ++++ .../Internal/JavaScriptStringArrayEncoder.cs | 43 +++ .../MissingAttributeLoggerStructure.cs | 12 +- .../Internal/ModeAttributes.cs | 25 ++ .../Internal/ModeAttributesOfT.cs | 25 ++ .../Internal/ModeMatchAttributes.cs | 39 +++ .../Internal/ModeMatchAttributesOfT.cs | 30 ++ .../Internal/ModeMatchResult.cs | 62 ++++ .../PartialAttributeLoggerStructure.cs | 74 ++++ .../JavaScriptUtility.cs | 110 ------ .../LinkTagHelper.cs | 234 +++++++++++-- .../Properties/AssemblyInfo.cs | 1 + .../ScriptTagHelper.cs | 8 +- .../TagHelperContextExtensions.cs | 66 ---- .../js/LinkTagHelper_FallbackJavaScript.js | 28 +- .../project.json | 4 +- ...HelpersWebSite.MvcTagHelper_Home.Link.html | 73 +++- .../Internal/AttributeMatcherTest.cs | 130 +++++++ .../Internal/GlobbingUrlBuilderTest.cs | 247 ++++++++++++++ .../Internal/JavaScriptResourcesTest.cs | 90 +++++ .../Internal/ModeMatchResultTest.cs | 134 ++++++++ .../JavaScriptUtilityTest.cs | 45 --- .../LinkTagHelperTest.cs | 316 +++++++++++++++--- .../ScriptTagHelperTest.cs | 1 + .../Views/MvcTagHelper_Home/Link.cshtml | 108 +++++- .../WebSites/MvcTagHelpersWebSite/appRoot.css | 8 + .../MvcTagHelpersWebSite/wwwroot/site.css | 6 + .../wwwroot/sub/site2.css | 6 + .../wwwroot/sub/site3.css | 7 + 38 files changed, 2132 insertions(+), 335 deletions(-) create mode 100644 samples/TagHelperSample.Web/appRoot.css delete mode 100644 samples/TagHelperSample.Web/wwwroot/blank.css create mode 100644 samples/TagHelperSample.Web/wwwroot/css/sub/blank.css create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/AttributeMatcher.cs create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/FileProviderGlobbingDirectory.cs create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/FileProviderGlobbingFile.cs create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/JavaScriptResources.cs create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/JavaScriptStringArrayEncoder.cs rename src/Microsoft.AspNet.Mvc.TagHelpers/{ => Internal}/MissingAttributeLoggerStructure.cs (86%) create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeAttributes.cs create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeAttributesOfT.cs create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchAttributes.cs create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchAttributesOfT.cs create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchResult.cs create mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/Internal/PartialAttributeLoggerStructure.cs delete mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/JavaScriptUtility.cs delete mode 100644 src/Microsoft.AspNet.Mvc.TagHelpers/TagHelperContextExtensions.cs create mode 100644 test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/AttributeMatcherTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/JavaScriptResourcesTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/ModeMatchResultTest.cs delete mode 100644 test/Microsoft.AspNet.Mvc.TagHelpers.Test/JavaScriptUtilityTest.cs create mode 100644 test/WebSites/MvcTagHelpersWebSite/appRoot.css create mode 100644 test/WebSites/MvcTagHelpersWebSite/wwwroot/site.css create mode 100644 test/WebSites/MvcTagHelpersWebSite/wwwroot/sub/site2.css create mode 100644 test/WebSites/MvcTagHelpersWebSite/wwwroot/sub/site3.css diff --git a/samples/TagHelperSample.Web/Views/Shared/_Layout.cshtml b/samples/TagHelperSample.Web/Views/Shared/_Layout.cshtml index fbad8b436c..71fe0e25d7 100644 --- a/samples/TagHelperSample.Web/Views/Shared/_Layout.cshtml +++ b/samples/TagHelperSample.Web/Views/Shared/_Layout.cshtml @@ -1,12 +1,13 @@  - - + + @RenderBody() diff --git a/samples/TagHelperSample.Web/appRoot.css b/samples/TagHelperSample.Web/appRoot.css new file mode 100644 index 0000000000..7b9214822c --- /dev/null +++ b/samples/TagHelperSample.Web/appRoot.css @@ -0,0 +1,8 @@ +body::after { + display: block; + background-color: #ff0000; + color: #fff; + font-size: 1.2em; + margin-top: 2.4em; + content: "ERROR: Stylesheet 'appRoot.css' was loaded from outside webroot!"; +} \ No newline at end of file diff --git a/samples/TagHelperSample.Web/project.json b/samples/TagHelperSample.Web/project.json index 628f1f61f2..5e9c73e3fc 100644 --- a/samples/TagHelperSample.Web/project.json +++ b/samples/TagHelperSample.Web/project.json @@ -1,6 +1,6 @@ { "commands": { - "web": "Microsoft.AspNet.Hosting server=Microsoft.AspNet.Server.WebListener server.urls=http://localhost:5001", + "web": "Microsoft.AspNet.Hosting server=Microsoft.AspNet.Server.WebListener server.urls=http://localhost:5001/taghelpers", "kestrel": "Microsoft.AspNet.Hosting --server Kestrel --server.urls http://localhost:5000" }, "compilationOptions": { diff --git a/samples/TagHelperSample.Web/wwwroot/blank.css b/samples/TagHelperSample.Web/wwwroot/blank.css deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/samples/TagHelperSample.Web/wwwroot/css/sub/blank.css b/samples/TagHelperSample.Web/wwwroot/css/sub/blank.css new file mode 100644 index 0000000000..2d91681f81 --- /dev/null +++ b/samples/TagHelperSample.Web/wwwroot/css/sub/blank.css @@ -0,0 +1 @@ +body {} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/AttributeMatcher.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/AttributeMatcher.cs new file mode 100644 index 0000000000..ee32847445 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/AttributeMatcher.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Microsoft.Framework.Logging; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + /// + /// Methods for determining how an should run based on the attributes that were specified. + /// + public static class AttributeMatcher + { + /// + /// Determines whether a 's required attributes are present, non null, non empty, and + /// non whitepsace. + /// + /// The . + /// + /// The attributes the requires in order to run. + /// + /// An optional to log warning details to. + /// A indicating whether the should run. + public static bool AllRequiredAttributesArePresent( + [NotNull] TagHelperContext context, + [NotNull] IEnumerable requiredAttributes, + ILogger logger) + { + var attributes = GetPresentMissingAttributes(context, requiredAttributes); + + if (attributes.Missing.Any()) + { + if (attributes.Present.Any() && logger != null && logger.IsEnabled(LogLevel.Warning)) + { + // At least 1 attribute was present indicating the user intended to use the tag helper, + // but at least 1 was missing too, so log a warning with the details. + logger.WriteWarning(new MissingAttributeLoggerStructure(context.UniqueId, attributes.Missing)); + } + + return false; + } + + // All required attributes present + return true; + } + + /// + /// Determines the modes a can run in based on which modes have all their required + /// attributes present, non null, non empty, and non whitepsace. + /// + /// The type representing the 's modes. + /// The . + /// The modes and their required attributes. + /// The . + public static ModeMatchResult DetermineMode( + [NotNull] TagHelperContext context, + [NotNull] IEnumerable> modeInfos) + { + // true == full match, false == partial match + var matchedAttributes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var result = new ModeMatchResult(); + + foreach (var modeInfo in modeInfos) + { + var modeAttributes = GetPresentMissingAttributes(context, modeInfo.Attributes); + + if (modeAttributes.Present.Any()) + { + if (!modeAttributes.Missing.Any()) + { + // A complete match, mark the attribute as fully matched + foreach (var attribute in modeAttributes.Present) + { + matchedAttributes[attribute] = true; + } + + result.FullMatches.Add(ModeMatchAttributes.Create(modeInfo.Mode, modeInfo.Attributes)); + } + else + { + // A partial match, mark the attribute as partially matched if not already fully matched + foreach (var attribute in modeAttributes.Present) + { + bool attributeMatch; + if (!matchedAttributes.TryGetValue(attribute, out attributeMatch)) + { + matchedAttributes[attribute] = false; + } + } + + result.PartialMatches.Add(ModeMatchAttributes.Create( + modeInfo.Mode, modeAttributes.Present, modeAttributes.Missing)); + } + } + } + + // Build the list of partially matched attributes (those with partial matches but no full matches) + foreach (var attribute in matchedAttributes.Keys) + { + if (!matchedAttributes[attribute]) + { + result.PartiallyMatchedAttributes.Add(attribute); + } + } + + return result; + } + + private static PresentMissingAttributes GetPresentMissingAttributes( + TagHelperContext context, + IEnumerable requiredAttributes) + { + // Check for all attribute values + var presentAttributes = new List(); + var missingAttributes = new List(); + + foreach (var attribute in requiredAttributes) + { + if (!context.AllAttributes.ContainsKey(attribute) || + context.AllAttributes[attribute] == null || + string.IsNullOrWhiteSpace(context.AllAttributes[attribute] as string)) + { + // Missing attribute! + missingAttributes.Add(attribute); + } + else + { + presentAttributes.Add(attribute); + } + } + + return new PresentMissingAttributes { Present = presentAttributes, Missing = missingAttributes }; + } + + private class PresentMissingAttributes + { + public IEnumerable Present { get; set; } + + public IEnumerable Missing { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/FileProviderGlobbingDirectory.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/FileProviderGlobbingDirectory.cs new file mode 100644 index 0000000000..f5b50f4925 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/FileProviderGlobbingDirectory.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.AspNet.FileProviders; +using Microsoft.Framework.FileSystemGlobbing.Abstractions; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + public class FileProviderGlobbingDirectory : DirectoryInfoBase + { + private const char DirectorySeparatorChar = '/'; + private readonly IFileProvider _fileProvider; + private readonly IFileInfo _fileInfo; + private readonly FileProviderGlobbingDirectory _parent; + private readonly bool _isRoot; + + public FileProviderGlobbingDirectory( + [NotNull] IFileProvider fileProvider, + IFileInfo fileInfo, + FileProviderGlobbingDirectory parent) + { + _fileProvider = fileProvider; + _fileInfo = fileInfo; + _parent = parent; + + if (_fileInfo == null) + { + // We're the root of the directory tree + RelativePath = string.Empty; + _isRoot = true; + } + else if (!string.IsNullOrEmpty(parent?.RelativePath)) + { + // We have a parent and they have a relative path so concat that with my name + RelativePath = _parent.RelativePath + DirectorySeparatorChar + _fileInfo.Name; + } + else + { + // We have a parent which is the root, so just use my name + RelativePath = _fileInfo.Name; + } + } + + public string RelativePath { get; } + + public override string FullName + { + get + { + if (_isRoot) + { + // We're the root, so just use our name + return Name; + } + + return _parent.FullName + DirectorySeparatorChar + Name; + } + } + + public override string Name + { + get + { + return _fileInfo?.Name; + } + } + + public override DirectoryInfoBase ParentDirectory + { + get + { + return _parent; + } + } + + public override IEnumerable EnumerateFileSystemInfos( + string searchPattern, + SearchOption searchOption) + { + if (!string.Equals(searchPattern, "*", StringComparison.OrdinalIgnoreCase)) + { + // Only * based searches are ever performed against this API and we have an item to change this API + // such that the searchPattern doesn't even get passed in, so this is just a safe-guard until then. + // The searchPattern here has no relation to the globbing pattern. + throw new ArgumentException( + "Only full wildcard searches are supported, i.e. \"*\".", + nameof(searchPattern)); + } + + if (searchOption != SearchOption.TopDirectoryOnly) + { + // Only SearchOption.TopDirectoryOnly is actually used in the implementation of Matcher and will likely + // be removed from DirectoryInfoBase in the near future. This is just a safe-guard until then. + // The searchOption here has no relation to the globbing pattern. + throw new ArgumentException( + $"Only {nameof(SearchOption.TopDirectoryOnly)} is supported.", + nameof(searchOption)); + } + + + + foreach (var fileInfo in _fileProvider.GetDirectoryContents(RelativePath)) + { + yield return BuildFileResult(fileInfo); + } + } + + public override DirectoryInfoBase GetDirectory(string path) + { + return new FileProviderGlobbingDirectory(_fileProvider, _fileProvider.GetFileInfo(path), this); + } + + public override FileInfoBase GetFile(string path) + { + return new FileProviderGlobbingFile(_fileProvider.GetFileInfo(path), this); + } + + private FileSystemInfoBase BuildFileResult(IFileInfo fileInfo) + { + if (fileInfo.IsDirectory) + { + return new FileProviderGlobbingDirectory(_fileProvider, fileInfo, this); + } + + return new FileProviderGlobbingFile(fileInfo, this); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/FileProviderGlobbingFile.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/FileProviderGlobbingFile.cs new file mode 100644 index 0000000000..3b8c9c6291 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/FileProviderGlobbingFile.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.FileProviders; +using Microsoft.Framework.FileSystemGlobbing.Abstractions; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + public class FileProviderGlobbingFile : FileInfoBase + { + private const char DirectorySeparatorChar = '/'; + + public FileProviderGlobbingFile([NotNull] IFileInfo fileInfo, [NotNull] DirectoryInfoBase parent) + { + Name = fileInfo.Name; + ParentDirectory = parent; + FullName = ParentDirectory.FullName + DirectorySeparatorChar + Name; + } + + public override string FullName { get; } + + public override string Name { get; } + + public override DirectoryInfoBase ParentDirectory { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs new file mode 100644 index 0000000000..2869d66c5b --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.FileProviders; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Microsoft.Framework.Cache.Memory; +using Microsoft.Framework.FileSystemGlobbing; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + /// + /// Utility methods for 's that support attributes containing file globbing patterns. + /// + public class GlobbingUrlBuilder + { + private static readonly char[] PatternSeparator = new[] { ',' }; + + private readonly FileProviderGlobbingDirectory _baseGlobbingDirectory; + + // Internal for testing + internal GlobbingUrlBuilder() { } + + /// + /// Creates a new . + /// + /// The file provider. + /// The cache. + /// The request path base. + public GlobbingUrlBuilder([NotNull] IFileProvider fileProvider, IMemoryCache cache, PathString requestPathBase) + { + FileProvider = fileProvider; + Cache = cache; + RequestPathBase = requestPathBase; + _baseGlobbingDirectory = new FileProviderGlobbingDirectory(fileProvider, fileInfo: null, parent: null); + } + + /// + /// The to cache globbing results in. + /// + public IMemoryCache Cache { get; } + + /// + /// The used to watch for changes to file globbing results. + /// + public IFileProvider FileProvider { get; } + + /// + /// The base path of the current request (i.e. ). + /// + public PathString RequestPathBase { get; } + + // Internal for testing. + internal Func MatcherBuilder { get; set; } + + /// + /// Builds a list of URLs. + /// + /// The statically declared URL. This will always be added to the result. + /// The file globbing include pattern. + /// The file globbing exclude pattern. + /// The list of URLs + public virtual IEnumerable BuildUrlList(string staticUrl, string includePattern, string excludePattern) + { + var urls = new HashSet(StringComparer.Ordinal); + + // Add the statically declared url if present + if (staticUrl != null) + { + urls.Add(staticUrl); + } + + // Add urls that match the globbing patterns specified + var matchedUrls = ExpandGlobbedUrl(includePattern, excludePattern); + urls.UnionWith(matchedUrls); + + return urls; + } + + private IEnumerable ExpandGlobbedUrl(string include, string exclude) + { + if (string.IsNullOrEmpty(include)) + { + return Enumerable.Empty(); + } + + var includePatterns = include.Split(PatternSeparator, StringSplitOptions.RemoveEmptyEntries); + var excludePatterns = exclude?.Split(PatternSeparator, StringSplitOptions.RemoveEmptyEntries); + + if (includePatterns.Length == 0) + { + return Enumerable.Empty(); + } + + if (Cache != null) + { + var cacheKey = $"{nameof(GlobbingUrlBuilder)}-inc:{include}-exc:{exclude}"; + return Cache.GetOrSet(cacheKey, cacheSetContext => + { + foreach (var pattern in includePatterns) + { + var trigger = FileProvider.Watch(pattern); + cacheSetContext.AddExpirationTrigger(trigger); + } + + return FindFiles(includePatterns, excludePatterns); + }); + } + + return FindFiles(includePatterns, excludePatterns); + } + + private IEnumerable FindFiles(IEnumerable includePatterns, IEnumerable excludePatterns) + { + var matcher = MatcherBuilder != null ? MatcherBuilder() : new Matcher(); + + matcher.AddIncludePatterns(includePatterns.Select(pattern => TrimLeadingSlash(pattern))); + + if (excludePatterns != null) + { + matcher.AddExcludePatterns(excludePatterns.Select(pattern => TrimLeadingSlash(pattern))); + } + + var matches = matcher.Execute(_baseGlobbingDirectory); + + return matches.Files.Select(ResolveMatchedPath); + } + + private string ResolveMatchedPath(string matchedPath) + { + // Resolve the path to site root + var relativePath = new PathString("/" + matchedPath); + return RequestPathBase.Add(relativePath).ToString(); + } + + private static string TrimLeadingSlash(string value) + { + var result = value; + + if (result.StartsWith("/", StringComparison.Ordinal) || + result.StartsWith("\\", StringComparison.Ordinal)) + { + // Trim the leading slash as the matcher runs from the provided root only anyway + result = result.Substring(1); + } + + return result; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/JavaScriptResources.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/JavaScriptResources.cs new file mode 100644 index 0000000000..fd1b5ffb0d --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/JavaScriptResources.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Reflection; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + /// + /// Methods for loading JavaScript from assembly embedded resources. + /// + public static class JavaScriptResources + { + private static readonly Assembly ResourcesAssembly = typeof(JavaScriptResources).GetTypeInfo().Assembly; + + private static readonly ConcurrentDictionary Cache = + new ConcurrentDictionary(StringComparer.Ordinal); + + /// + /// Gets an embedded JavaScript file resource and decodes it for use as a .NET format string. + /// + public static string GetEmbeddedJavaScript(string resourceName) + { + return GetEmbeddedJavaScript(resourceName, ResourcesAssembly.GetManifestResourceStream, Cache); + } + + // Internal for testing + internal static string GetEmbeddedJavaScript( + string resourceName, + Func getManifestResourceStream, + ConcurrentDictionary cache) + { + return cache.GetOrAdd(resourceName, key => + { + // Load the JavaScript from embedded resource + using (var resourceStream = getManifestResourceStream(key)) + { + Debug.Assert(resourceStream != null, "Embedded resource missing. Ensure 'prebuild' script has run."); + + using (var streamReader = new StreamReader(resourceStream)) + { + var script = streamReader.ReadToEnd(); + + return PrepareFormatString(script); + } + } + }); + } + + private static string PrepareFormatString(string input) + { + // Replace unescaped/escaped chars with their equivalent + return input.Replace("{", "{{") + .Replace("}", "}}") + .Replace("[[[", "{") + .Replace("]]]", "}"); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/JavaScriptStringArrayEncoder.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/JavaScriptStringArrayEncoder.cs new file mode 100644 index 0000000000..15c4e358ef --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/JavaScriptStringArrayEncoder.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Text; +using Microsoft.AspNet.WebUtilities.Encoders; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + /// + /// Methods for encoding for use as a JavaScript array literal. + /// + public static class JavaScriptStringArrayEncoder + { + /// + /// Encodes a .NET string array for safe use as a JavaScript array literal, including inline in an HTML file. + /// + public static string Encode(IJavaScriptStringEncoder encoder, IEnumerable values) + { + var builder = new StringBuilder(); + + var firstAdded = false; + + builder.Append('['); + + foreach (var value in values) + { + if (firstAdded) + { + builder.Append(','); + } + builder.Append('"'); + builder.Append(encoder.JavaScriptStringEncode(value)); + builder.Append('"'); + firstAdded = true; + } + + builder.Append(']'); + + return builder.ToString(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/MissingAttributeLoggerStructure.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/MissingAttributeLoggerStructure.cs similarity index 86% rename from src/Microsoft.AspNet.Mvc.TagHelpers/MissingAttributeLoggerStructure.cs rename to src/Microsoft.AspNet.Mvc.TagHelpers/Internal/MissingAttributeLoggerStructure.cs index 228e05e144..b5818101c6 100644 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/MissingAttributeLoggerStructure.cs +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/MissingAttributeLoggerStructure.cs @@ -5,10 +5,10 @@ using System.Collections.Generic; using Microsoft.AspNet.Razor.Runtime.TagHelpers; using Microsoft.Framework.Logging; -namespace Microsoft.AspNet.Mvc.TagHelpers +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal { /// - /// An for log messages regarding instances that opt out of + /// An for log messages regarding instances that opt out of /// processing due to missing required attributes. /// public class MissingAttributeLoggerStructure : ILoggerStructure @@ -16,7 +16,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers private readonly string _uniqueId; private readonly IEnumerable> _values; - // internal for unit testing. + // Internal for unit testing internal IEnumerable MissingAttributes { get; } /// @@ -30,8 +30,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers MissingAttributes = missingAttributes; _values = new Dictionary { - { "UniqueId", _uniqueId }, - { "MissingAttributes", MissingAttributes } + ["UniqueId"] = _uniqueId, + ["MissingAttributes"] = MissingAttributes }; } @@ -42,7 +42,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers { get { - return "Tag Helper skipped due to missing required attributes."; + return "Tag Helper has one or more missing required attributes."; } } diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeAttributes.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeAttributes.cs new file mode 100644 index 0000000000..85b178c728 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeAttributes.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + /// + /// Static creation methods for . + /// + public static class ModeAttributes + { + /// + /// Creates an / + /// + public static ModeAttributes Create(TMode mode, IEnumerable attributes) + { + return new ModeAttributes + { + Mode = mode, + Attributes = attributes + }; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeAttributesOfT.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeAttributesOfT.cs new file mode 100644 index 0000000000..32578d3ef4 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeAttributesOfT.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + /// + /// A mapping of a mode to its required attributes. + /// + /// The type representing the 's mode. + public class ModeAttributes + { + /// + /// The 's mode. + /// + public TMode Mode { get; set; } + + /// + /// The names of attributes required for this mode. + /// + public IEnumerable Attributes { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchAttributes.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchAttributes.cs new file mode 100644 index 0000000000..409bb6d86d --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchAttributes.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + /// + /// Static creation methods for . + /// + public static class ModeMatchAttributes + { + /// + /// Creates an . + /// + public static ModeMatchAttributes Create( + TMode mode, + IEnumerable presentAttributes) + { + return Create(mode, presentAttributes, missingAttributes: null); + } + + /// + /// Creates an . + /// + public static ModeMatchAttributes Create( + TMode mode, + IEnumerable presentAttributes, + IEnumerable missingAttributes) + { + return new ModeMatchAttributes + { + Mode = mode, + PresentAttributes = presentAttributes, + MissingAttributes = missingAttributes + }; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchAttributesOfT.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchAttributesOfT.cs new file mode 100644 index 0000000000..82bbc56d88 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchAttributesOfT.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + /// + /// A mapping of a mode to its missing and present attributes. + /// + /// The type representing the 's mode. + public class ModeMatchAttributes + { + /// + /// The 's mode. + /// + public TMode Mode { get; set; } + + /// + /// The names of attributes that were present in this match. + /// + public IEnumerable PresentAttributes { get; set; } + + /// + /// The names of attributes that were missing in this match. + /// + public IEnumerable MissingAttributes { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchResult.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchResult.cs new file mode 100644 index 0000000000..085640eb2a --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/ModeMatchResult.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Microsoft.Framework.Logging; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + /// + /// Result of determining the mode an will run in. + /// + /// The type representing the 's mode. + public class ModeMatchResult + { + /// + /// Modes that were missing attributes but had at least one attribute present. + /// + public IList> PartialMatches { get; } = new List>(); + + /// + /// Modes that had all attributes present. + /// + public IList> FullMatches { get; } = new List>(); + + /// + /// Attributes that are present in at least one mode in , but in no modes in + /// . + /// + public IList PartiallyMatchedAttributes { get; } = new List(); + + /// + /// Logs the details of the . + /// + /// The . + /// The . + /// The value of . + public void LogDetails([NotNull] ILogger logger, [NotNull] TTagHelper tagHelper, string uniqueId) + where TTagHelper : ITagHelper + { + if (logger.IsEnabled(LogLevel.Warning) && PartiallyMatchedAttributes.Any()) + { + // Build the list of partial matches that contain attributes not appearing in at least one full match + var partialOnlyMatches = PartialMatches.Where( + match => match.PresentAttributes.Any( + attribute => PartiallyMatchedAttributes.Contains( + attribute, StringComparer.OrdinalIgnoreCase))); + + logger.WriteWarning(new PartialModeMatchLoggerStructure(uniqueId, partialOnlyMatches)); + } + + if (logger.IsEnabled(LogLevel.Verbose) && !FullMatches.Any()) + { + logger.WriteVerbose("Skipping processing for {0} {1}", + tagHelper.GetType().GetTypeInfo().FullName, uniqueId); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/PartialAttributeLoggerStructure.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/PartialAttributeLoggerStructure.cs new file mode 100644 index 0000000000..c4e0a8eaba --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/PartialAttributeLoggerStructure.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Microsoft.Framework.Logging; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + /// + /// An for log messages regarding instances that opt out of + /// processing due to missing attributes for one of several possible modes. + /// + public class PartialModeMatchLoggerStructure : ILoggerStructure + { + private readonly string _uniqueId; + private readonly IEnumerable> _partialMatches; + private readonly IEnumerable> _values; + + /// + /// Creates a new . + /// + /// The unique ID of the HTML element this message applies to. + /// The set of modes with partial required attributes. + public PartialModeMatchLoggerStructure( + string uniqueId, + [NotNull] IEnumerable> partialMatches) + { + _uniqueId = uniqueId; + _partialMatches = partialMatches; + _values = new Dictionary + { + ["UniqueId"] = _uniqueId, + ["PartialMatches"] = partialMatches + }; + } + + /// + /// The log message. + /// + public string Message + { + get + { + return "Tag Helper has missing required attributes."; + } + } + + /// + /// Gets the values associated with this structured log message. + /// + /// The values. + public IEnumerable> GetValues() + { + return _values; + } + + /// + /// Generates a human readable string for this structured log message. + /// + /// The message. + public string Format() + { + var newLine = Environment.NewLine; + return + string.Format($"Tag Helper {_uniqueId} had partial matches while determining mode:{newLine}\t{{0}}", + string.Join($"{newLine}\t", _partialMatches.Select(partial => + string.Format($"Mode '{partial.Mode}' missing attributes:{newLine}\t\t{{0}} ", + string.Join($"{newLine}\t\t", partial.MissingAttributes))))); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/JavaScriptUtility.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/JavaScriptUtility.cs deleted file mode 100644 index dfeb359a2d..0000000000 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/JavaScriptUtility.cs +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Reflection; -using System.Text; - -namespace Microsoft.AspNet.Mvc.TagHelpers -{ - /// - /// Utility methods for dealing with JavaScript. - /// - public static class JavaScriptUtility - { - private static readonly Assembly ResourcesAssembly = typeof(JavaScriptUtility).GetTypeInfo().Assembly; - - private static readonly ConcurrentDictionary Cache = - new ConcurrentDictionary(StringComparer.Ordinal); - - private static readonly IDictionary EncodingMap = new Dictionary - { - { '<', @"\u003c" }, // opening angle-bracket - { '>', @"\u003e" }, // closing angle-bracket - { '\'', @"\u0027" }, // single quote - { '"', @"\u0022" }, // double quote - { '\\', @"\\" }, // back slash - { '\r', "\\r" }, // carriage return - { '\n', "\\n" }, // new line - { '\u0085', @"\u0085" }, // next line - { '&', @"\u0026" }, // ampersand - }; - - /// - /// Gets an embedded JavaScript file resource and decodes it for use as a .NET format string. - /// - public static string GetEmbeddedJavaScript(string resourceName) - { - return Cache.GetOrAdd(resourceName, key => - { - // Load the JavaScript from embedded resource - using (var resourceStream = ResourcesAssembly.GetManifestResourceStream(key)) - { - Debug.Assert(resourceStream != null, "Embedded resource missing. Ensure 'prebuild' script has run."); - - using (var streamReader = new StreamReader(resourceStream)) - { - var script = streamReader.ReadToEnd(); - - // Replace unescaped/escaped chars with their equivalent - return PrepareFormatString(script); - } - } - }); - } - - // Internal so we can test this separately - internal static string PrepareFormatString(string input) - { - return input.Replace("{", "{{") - .Replace("}", "}}") - .Replace("[[[", "{") - .Replace("]]]", "}"); - } - - /// - /// Encodes a .NET string for safe use as a JavaScript string literal, including inline in an HTML file. - /// - internal static string JavaScriptStringEncode(string value) - { - var result = new StringBuilder(); - - foreach (var character in value) - { - if (CharRequiresJavaScriptEncoding(character)) - { - EncodeAndAppendChar(result, character); - } - else - { - result.Append(character); - } - } - - return result.ToString(); - } - - private static bool CharRequiresJavaScriptEncoding(char character) - { - return character < 0x20 // Control chars - || EncodingMap.ContainsKey(character); - } - - private static void EncodeAndAppendChar(StringBuilder builder, char character) - { - string mapped; - - if (!EncodingMap.TryGetValue(character, out mapped)) - { - mapped = "\\u" + ((int)character).ToString("x4", CultureInfo.InvariantCulture); - } - - builder.Append(mapped); - } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/LinkTagHelper.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/LinkTagHelper.cs index 0f09635b80..1fe47b3082 100644 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/LinkTagHelper.cs +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/LinkTagHelper.cs @@ -1,9 +1,17 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; +using System.Linq; +using System.Net; using System.Text; +using Microsoft.AspNet.Hosting; +using Microsoft.AspNet.Mvc.TagHelpers.Internal; using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Microsoft.AspNet.WebUtilities.Encoders; +using Microsoft.Framework.Cache.Memory; using Microsoft.Framework.Logging; namespace Microsoft.AspNet.Mvc.TagHelpers @@ -13,22 +21,77 @@ namespace Microsoft.AspNet.Mvc.TagHelpers /// public class LinkTagHelper : TagHelper { + private const string HrefIncludeAttributeName = "asp-href-include"; + private const string HrefExcludeAttributeName = "asp-href-exclude"; private const string FallbackHrefAttributeName = "asp-fallback-href"; + private const string FallbackHrefIncludeAttributeName = "asp-fallback-href-include"; + private const string FallbackHrefExcludeAttributeName = "asp-fallback-href-exclude"; private const string FallbackTestClassAttributeName = "asp-fallback-test-class"; private const string FallbackTestPropertyAttributeName = "asp-fallback-test-property"; private const string FallbackTestValueAttributeName = "asp-fallback-test-value"; - private const string FallbackTestMetaTemplate = ""; private const string FallbackJavaScriptResourceName = "compiler/resources/LinkTagHelper_FallbackJavaScript.js"; - // NOTE: All attributes are required for the LinkTagHelper to process. - private static readonly string[] RequiredAttributes = new[] - { - FallbackHrefAttributeName, - FallbackTestClassAttributeName, - FallbackTestPropertyAttributeName, - FallbackTestValueAttributeName + private static readonly ModeAttributes[] ModeDetails = new[] { + // Globbed Href (include only) no static href + ModeAttributes.Create(Mode.GlobbedHref, new [] { HrefIncludeAttributeName }), + // Globbed Href (include & exclude), no static href + ModeAttributes.Create(Mode.GlobbedHref, new [] { HrefIncludeAttributeName, HrefExcludeAttributeName }), + // Fallback with static href + ModeAttributes.Create( + Mode.Fallback, new[] + { + FallbackHrefAttributeName, + FallbackTestClassAttributeName, + FallbackTestPropertyAttributeName, + FallbackTestValueAttributeName + }), + // Fallback with globbed href (include only) + ModeAttributes.Create( + Mode.Fallback, new[] { + FallbackHrefIncludeAttributeName, + FallbackTestClassAttributeName, + FallbackTestPropertyAttributeName, + FallbackTestValueAttributeName + }), + // Fallback with globbed href (include & exclude) + ModeAttributes.Create( + Mode.Fallback, new[] { + FallbackHrefIncludeAttributeName, + FallbackHrefExcludeAttributeName, + FallbackTestClassAttributeName, + FallbackTestPropertyAttributeName, + FallbackTestValueAttributeName + }), }; + private enum Mode + { + /// + /// Rendering a fallback block if primary stylesheet fails to load. Will also do globbing if the appropriate + /// properties are set. + /// + Fallback, + /// + /// Just performing file globbing search for the href, rendering a separate <link> for each match. + /// + GlobbedHref + } + + /// + /// A comma separated list of globbed file patterns of CSS stylesheets to load. + /// The glob patterns are assessed relative to the application's 'webroot' setting. + /// + [HtmlAttributeName(HrefIncludeAttributeName)] + public string HrefInclude { get; set; } + + /// + /// A comma separated list of globbed file patterns of CSS stylesheets to exclude from loading. + /// The glob patterns are assessed relative to the application's 'webroot' setting. + /// Must be used in conjunction with . + /// + [HtmlAttributeName(HrefExcludeAttributeName)] + public string HrefExclude { get; set; } + /// /// The URL of a CSS stylesheet to fallback to in the case the primary one fails (as specified in the href /// attribute). @@ -36,70 +99,171 @@ namespace Microsoft.AspNet.Mvc.TagHelpers [HtmlAttributeName(FallbackHrefAttributeName)] public string FallbackHref { get; set; } + /// + /// A comma separated list of globbed file patterns of CSS stylesheets to fallback to in the case the primary + /// one fails (as specified in the href attribute). + /// The glob patterns are assessed relative to the application's 'webroot' setting. + /// + [HtmlAttributeName(FallbackHrefIncludeAttributeName)] + public string FallbackHrefInclude { get; set; } + + /// + /// A comma separated list of globbed file patterns of CSS stylesheets to exclude from the fallback list, in + /// the case the primary one fails (as specified in the href attribute). + /// The glob patterns are assessed relative to the application's 'webroot' setting. + /// Must be used in conjunction with . + /// + [HtmlAttributeName(FallbackHrefExcludeAttributeName)] + public string FallbackHrefExclude { get; set; } + /// /// The class name defined in the stylesheet to use for the fallback test. + /// Must be used in conjunction with and , + /// and either or . /// [HtmlAttributeName(FallbackTestClassAttributeName)] public string FallbackTestClass { get; set; } /// /// The CSS property name to use for the fallback test. + /// Must be used in conjunction with and , + /// and either or . /// [HtmlAttributeName(FallbackTestPropertyAttributeName)] public string FallbackTestProperty { get; set; } /// /// The CSS property value to use for the fallback test. + /// Must be used in conjunction with and , + /// and either or . /// [HtmlAttributeName(FallbackTestValueAttributeName)] public string FallbackTestValue { get; set; } - // Protected to ensure subclasses are correctly activated. Internal for ease of use when testing. + // Properties are protected to ensure subclasses are correctly activated. Internal for ease of use when testing. [Activate] protected internal ILogger Logger { get; set; } + [Activate] + protected internal IHostingEnvironment HostingEnvironment { get; set; } + + [Activate] + protected internal ViewContext ViewContext { get; set; } + + [Activate] + protected internal IMemoryCache Cache { get; set; } + + // Internal for ease of use when testing. + protected internal GlobbingUrlBuilder GlobbingUrlBuilder { get; set; } + /// public override void Process(TagHelperContext context, TagHelperOutput output) { - if (!context.AllRequiredAttributesArePresent(RequiredAttributes, Logger)) + var modeResult = AttributeMatcher.DetermineMode(context, ModeDetails); + + Debug.Assert(modeResult.FullMatches.Select(match => match.Mode).Distinct().Count() <= 1, + $"There should only be one mode match, check the {nameof(ModeDetails)}"); + + modeResult.LogDetails(Logger, this, context.UniqueId); + + if (!modeResult.FullMatches.Any()) { - if (Logger.IsEnabled(LogLevel.Verbose)) - { - Logger.WriteVerbose("Skipping processing for {0} {1}", nameof(LinkTagHelper), context.UniqueId); - } + // No attributes matched so we have nothing to do return; } - var content = new StringBuilder(); + var mode = modeResult.FullMatches.First().Mode; // NOTE: Values in TagHelperOutput.Attributes are already HtmlEncoded + var attributes = new Dictionary(output.Attributes); - // We've taken over rendering here so prevent the element rendering the outer tag - output.TagName = null; + var builder = new StringBuilder(); - // Rebuild the tag that loads the primary stylesheet - content.Append(" tag to match the original one in the source file + BuildLinkTag(attributes, builder); + } + else + { + BuildGlobbedLinkTags(attributes, builder); } - content.AppendLine("/>"); - // Build the tag that's used to test for the presence of the stylesheet - content.AppendLine(string.Format(CultureInfo.InvariantCulture, FallbackTestMetaTemplate, FallbackTestClass)); + if (mode == Mode.Fallback) + { + BuildFallbackBlock(builder); + } - // Build the "); + // We've taken over tag rendering, so prevent rendering the outer tag + output.TagName = null; + output.Content = builder.ToString(); + } - output.Content = content.ToString(); + private void BuildGlobbedLinkTags(IDictionary attributes, StringBuilder builder) + { + // Build a tag for each matched href as well as the original one in the source file + string staticHref; + attributes.TryGetValue("href", out staticHref); + + EnsureGlobbingUrlBuilder(); + var hrefs = GlobbingUrlBuilder.BuildUrlList(staticHref, HrefInclude, HrefExclude); + + foreach (var href in hrefs) + { + attributes["href"] = WebUtility.HtmlEncode(href); + BuildLinkTag(attributes, builder); + } + } + + private void BuildFallbackBlock(StringBuilder builder) + { + EnsureGlobbingUrlBuilder(); + var fallbackHrefs = GlobbingUrlBuilder.BuildUrlList(FallbackHref, FallbackHrefInclude, FallbackHrefExclude); + + if (fallbackHrefs.Any()) + { + builder.AppendLine(); + + // Build the tag that's used to test for the presence of the stylesheet + builder.AppendFormat( + CultureInfo.InvariantCulture, + "", + WebUtility.HtmlEncode(FallbackTestClass)); + + // Build the "); + } + } + + private void EnsureGlobbingUrlBuilder() + { + if (GlobbingUrlBuilder == null) + { + GlobbingUrlBuilder = new GlobbingUrlBuilder( + HostingEnvironment.WebRootFileProvider, + Cache, + ViewContext.HttpContext.Request.PathBase); + } + } + + private static void BuildLinkTag(IDictionary attributes, StringBuilder builder) + { + builder.Append(""); } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/AssemblyInfo.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/AssemblyInfo.cs index 58841d9458..274352fe87 100644 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Properties/AssemblyInfo.cs @@ -4,3 +4,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.AspNet.Mvc.TagHelpers.Test")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/ScriptTagHelper.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/ScriptTagHelper.cs index eac3a38d69..fda4d1cdc8 100644 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/ScriptTagHelper.cs +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/ScriptTagHelper.cs @@ -6,7 +6,9 @@ using System.Globalization; using System.Net; using System.Text; using System.Threading.Tasks; +using Microsoft.AspNet.Mvc.TagHelpers.Internal; using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Microsoft.AspNet.WebUtilities.Encoders; using Microsoft.Framework.Logging; namespace Microsoft.AspNet.Mvc.TagHelpers @@ -51,7 +53,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers /// public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { - if (!context.AllRequiredAttributesArePresent(RequiredAttributes, Logger)) + if (!AttributeMatcher.AllRequiredAttributesArePresent(context, RequiredAttributes, Logger)) { if (Logger.IsEnabled(LogLevel.Verbose)) { @@ -95,8 +97,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers { if (!attribute.Key.Equals(SrcAttributeName, StringComparison.OrdinalIgnoreCase)) { - var encodedKey = JavaScriptUtility.JavaScriptStringEncode(attribute.Key); - var encodedValue = JavaScriptUtility.JavaScriptStringEncode(attribute.Value); + var encodedKey = JavaScriptStringEncoder.Default.JavaScriptStringEncode(attribute.Key); + var encodedValue = JavaScriptStringEncoder.Default.JavaScriptStringEncode(attribute.Value); content.AppendFormat(CultureInfo.InvariantCulture, " {0}=\\\"{1}\\\"", encodedKey, encodedValue); } diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/TagHelperContextExtensions.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/TagHelperContextExtensions.cs deleted file mode 100644 index e1bde2e51b..0000000000 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/TagHelperContextExtensions.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Framework.Logging; -using Microsoft.AspNet.Mvc; -using Microsoft.AspNet.Mvc.TagHelpers; - -namespace Microsoft.AspNet.Razor.Runtime.TagHelpers -{ - /// - /// Utility related extensions for . - /// - public static class TagHelperContextExtensions - { - /// - /// Determines whether a 's required attributes are present, non null, non empty, and - /// non whitepsace. - /// - /// The . - /// The attributes the requires in order to run. - /// An optional to log warning details to. - /// A indicating whether the should run. - public static bool AllRequiredAttributesArePresent( - [NotNull]this TagHelperContext context, - [NotNull]IEnumerable requiredAttributes, - ILogger logger = null) - { - // Check for all attribute values & log a warning if any required are missing - var atLeastOnePresent = false; - var missingAttrNames = new List(); - - foreach (var attr in requiredAttributes) - { - if (!context.AllAttributes.ContainsKey(attr) - || context.AllAttributes[attr] == null - || string.IsNullOrWhiteSpace(context.AllAttributes[attr] as string)) - { - // Missing attribute! - missingAttrNames.Add(attr); - } - else - { - atLeastOnePresent = true; - } - } - - if (missingAttrNames.Any()) - { - if (atLeastOnePresent && logger != null && logger.IsEnabled(LogLevel.Warning)) - { - // At least 1 attribute was present indicating the user intended to use the tag helper, - // but at least 1 was missing too, so log a warning with the details. - logger.WriteWarning(new MissingAttributeLoggerStructure(context.UniqueId, missingAttrNames)); - } - - return false; - } - - // All required attributes present - return true; - } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/js/LinkTagHelper_FallbackJavaScript.js b/src/Microsoft.AspNet.Mvc.TagHelpers/js/LinkTagHelper_FallbackJavaScript.js index 0dc37d59f0..e68f3a92d5 100644 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/js/LinkTagHelper_FallbackJavaScript.js +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/js/LinkTagHelper_FallbackJavaScript.js @@ -1,12 +1,26 @@ -(function (cssTestPropertyName, cssTestPropertyValue, fallbackHref) { - // This function finds the previous element (assumed to be meta) and tests its current CSS style using the passed - // values, to determine if a stylesheet was loaded. If not, this function loads the fallback stylesheet via - // document.write. +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +( + /** + * This function finds the previous element (assumed to be meta) and tests its current CSS style using the passed + * values, to determine if a stylesheet was loaded. If not, this function loads the fallback stylesheet via + * document.write. + * + * @param {string} cssTestPropertyName - The name of the CSS property to test. + * @param {string} cssTestPropertyValue - The value to test the specified CSS property for. + * @param {string[]} fallbackHref - The URLs to the stylesheets to load in the case the test fails. + */ + function loadFallbackStylesheet(cssTestPropertyName, cssTestPropertyValue, fallbackHref) { var doc = document, + // Find the last script tag on the page which will be this one, as JS executes as it loads scriptElements = doc.getElementsByTagName("SCRIPT"), - meta = scriptElements[scriptElements.length - 1].previousElementSibling; + // Find the meta tag before this script tag, that's the element we're going to test the CSS property on + meta = scriptElements[scriptElements.length - 1].previousElementSibling, + i; if (doc.defaultView.getComputedStyle(meta)[cssTestPropertyName] !== cssTestPropertyValue) { - doc.write(''); + for (i = 0; i < fallbackHref.length; i++) { + doc.write(''); + } } -})("[[[0]]]", "[[[1]]]", "[[[2]]]"); \ No newline at end of file +})("[[[0]]]", "[[[1]]]", [[[2]]]); \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/project.json b/src/Microsoft.AspNet.Mvc.TagHelpers/project.json index c32fd00f7b..27d305906d 100644 --- a/src/Microsoft.AspNet.Mvc.TagHelpers/project.json +++ b/src/Microsoft.AspNet.Mvc.TagHelpers/project.json @@ -7,7 +7,9 @@ "dependencies": { "Microsoft.AspNet.Mvc.Common": { "version": "6.0.0-*", "type": "build" }, "Microsoft.AspNet.Mvc.Razor": "6.0.0-*", - "Microsoft.Framework.Logging.Interfaces": "1.0.0-*", + "Microsoft.Framework.Cache.Memory": "1.0.0-*", + "Microsoft.Framework.FileSystemGlobbing": "1.0.0-*", + "Microsoft.Framework.Logging.Interfaces": { "version": "1.0.0-*", "type": "build" }, "System.Security.Cryptography.Hashing.Algorithms": "4.0.0-beta-*" }, "frameworks": { diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/Compiler/Resources/MvcTagHelpersWebSite.MvcTagHelper_Home.Link.html b/test/Microsoft.AspNet.Mvc.FunctionalTests/Compiler/Resources/MvcTagHelpersWebSite.MvcTagHelper_Home.Link.html index 07e924e710..d06c5a376c 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/Compiler/Resources/MvcTagHelpersWebSite.MvcTagHelper_Home.Link.html +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/Compiler/Resources/MvcTagHelpersWebSite.MvcTagHelper_Home.Link.html @@ -4,10 +4,75 @@ Link - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/AttributeMatcherTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/AttributeMatcherTest.cs new file mode 100644 index 0000000000..964e820464 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/AttributeMatcherTest.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Xunit; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + public class AttributeMatcherTest + { + [Fact] + public void DetermineMode_FindsFullModeMatchWithSingleAttribute() + { + // Arrange + var modeInfo = new [] + { + ModeAttributes.Create("mode0", new [] { "first-attr" }) + }; + var attributes = new Dictionary + { + ["first-attr"] = "value", + ["not-in-any-mode"] = "value" + }; + var context = MakeTagHelperContext(attributes); + + // Act + var modeMatch = AttributeMatcher.DetermineMode(context, modeInfo); + + // Assert + Assert.Collection(modeMatch.FullMatches, match => + { + Assert.Equal("mode0", match.Mode); + Assert.Collection(match.PresentAttributes, attribute => Assert.Equal("first-attr", attribute)); + }); + Assert.Empty(modeMatch.PartialMatches); + Assert.Empty(modeMatch.PartiallyMatchedAttributes); + } + + [Fact] + public void DetermineMode_FindsFullModeMatchWithMultipleAttributes() + { + // Arrange + var modeInfo = new[] + { + ModeAttributes.Create("mode0", new [] { "first-attr", "second-attr" }) + }; + var attributes = new Dictionary + { + ["first-attr"] = "value", + ["second-attr"] = "value", + ["not-in-any-mode"] = "value" + }; + var context = MakeTagHelperContext(attributes); + + // Act + var modeMatch = AttributeMatcher.DetermineMode(context, modeInfo); + + // Assert + Assert.Collection(modeMatch.FullMatches, match => + { + Assert.Equal("mode0", match.Mode); + Assert.Collection(match.PresentAttributes, + attribute => Assert.Equal("first-attr", attribute), + attribute => Assert.Equal("second-attr", attribute) + ); + }); + Assert.Empty(modeMatch.PartialMatches); + Assert.Empty(modeMatch.PartiallyMatchedAttributes); + } + + [Fact] + public void DetermineMode_FindsFullAndPartialModeMatchWithMultipleAttribute() + { + // Arrange + var modeInfo = new[] + { + ModeAttributes.Create("mode0", new [] { "second-attr" }), + ModeAttributes.Create("mode1", new [] { "first-attr", "third-attr" }), + ModeAttributes.Create("mode2", new [] { "first-attr", "second-attr", "third-attr" }), + ModeAttributes.Create("mode3", new [] { "fourth-attr" }) + }; + var attributes = new Dictionary + { + ["second-attr"] = "value", + ["third-attr"] = "value", + ["not-in-any-mode"] = "value" + }; + var context = MakeTagHelperContext(attributes); + + // Act + var modeMatch = AttributeMatcher.DetermineMode(context, modeInfo); + + // Assert + Assert.Collection(modeMatch.FullMatches, match => + { + Assert.Equal("mode0", match.Mode); + Assert.Collection(match.PresentAttributes, attribute => Assert.Equal("second-attr", attribute)); + }); + Assert.Collection(modeMatch.PartialMatches, + match => + { + Assert.Equal("mode1", match.Mode); + Assert.Collection(match.PresentAttributes, attribute => Assert.Equal("third-attr", attribute)); + Assert.Collection(match.MissingAttributes, attribute => Assert.Equal("first-attr", attribute)); + }, + match => + { + Assert.Equal("mode2", match.Mode); + Assert.Collection(match.PresentAttributes, + attribute => Assert.Equal("second-attr", attribute), + attribute => Assert.Equal("third-attr", attribute) + ); + Assert.Collection(match.MissingAttributes, attribute => Assert.Equal("first-attr", attribute)); + }); + Assert.Collection(modeMatch.PartiallyMatchedAttributes, attribute => Assert.Equal("third-attr", attribute)); + } + + private static TagHelperContext MakeTagHelperContext( + IDictionary attributes = null, + string content = null) + { + attributes = attributes ?? new Dictionary(); + + return new TagHelperContext(attributes, Guid.NewGuid().ToString("N"), () => Task.FromResult(content)); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs new file mode 100644 index 0000000000..62bc4925c4 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.FileProviders; +using Microsoft.AspNet.Http; +using Microsoft.Framework.Cache.Memory; +using Microsoft.Framework.Expiration.Interfaces; +using Microsoft.Framework.FileSystemGlobbing; +using Microsoft.Framework.FileSystemGlobbing.Abstractions; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + public class GlobbingUrlBuilderTest + { + [Fact] + public void ReturnsOnlyStaticUrlWhenPatternDoesntFindAnyMatches() + { + // Arrange + var fileProvider = MakeFileProvider(); + IMemoryCache cache = null; + var requestPathBase = PathString.Empty; + var globbingUrlBuilder = new GlobbingUrlBuilder(fileProvider, cache, requestPathBase); + + // Act + var urlList = globbingUrlBuilder.BuildUrlList("/site.css", "**/*.css", excludePattern: null); + + // Assert + Assert.Collection(urlList, url => Assert.Equal("/site.css", url)); + } + + [Fact] + public void DedupesStaticUrlAndPatternMatches() + { + // Arrange + var fileProvider = MakeFileProvider(MakeDirectoryContents("site.css", "blank.css")); + IMemoryCache cache = null; + var requestPathBase = PathString.Empty; + var globbingUrlBuilder = new GlobbingUrlBuilder(fileProvider, cache, requestPathBase); + + // Act + var urlList = globbingUrlBuilder.BuildUrlList("/site.css", "**/*.css", excludePattern: null); + + // Assert + Assert.Collection(urlList, + url => Assert.Equal("/site.css", url), + url => Assert.Equal("/blank.css", url)); + } + + [Theory] + [InlineData("/sub")] + [InlineData("/sub/again")] + public void ResolvesMatchedUrlsAgainstPathBase(string pathBase) + { + // Arrange + var fileProvider = MakeFileProvider(MakeDirectoryContents("site.css", "blank.css")); + IMemoryCache cache = null; + var requestPathBase = new PathString(pathBase); + var globbingUrlBuilder = new GlobbingUrlBuilder(fileProvider, cache, requestPathBase); + + // Act + var urlList = globbingUrlBuilder.BuildUrlList( + staticUrl: null, + includePattern: "**/*.css", + excludePattern: null); + + // Assert + Assert.Collection(urlList, + url => Assert.Equal($"{pathBase}/site.css", url), + url => Assert.Equal($"{pathBase}/blank.css", url)); + } + + [Fact] + public void UsesCachedMatchResults() + { + // Arrange + var fileProvider = MakeFileProvider(); + var cache = MakeCache(new List { "/site.css", "/blank.css" }); + var requestPathBase = PathString.Empty; + var globbingUrlBuilder = new GlobbingUrlBuilder(fileProvider, cache, requestPathBase); + + // Act + var urlList = globbingUrlBuilder.BuildUrlList( + staticUrl: null, + includePattern: "**/*.css", + excludePattern: null); + + // Assert + Assert.Collection(urlList, + url => Assert.Equal("/site.css", url), + url => Assert.Equal("/blank.css", url)); + } + + [Fact] + public void CachesMatchResults() + { + // Arrange + var trigger = new Mock(); + var fileProvider = MakeFileProvider(MakeDirectoryContents("site.css", "blank.css")); + Mock.Get(fileProvider).Setup(f => f.Watch(It.IsAny())).Returns(trigger.Object); + var cache = MakeCache(); + var cacheSetContext = new Mock(); + cacheSetContext.Setup(c => c.AddExpirationTrigger(trigger.Object)).Verifiable(); + Mock.Get(cache).Setup(c => c.Set( + /*key*/ It.IsAny(), + /*link*/ It.IsAny(), + /*state*/ It.IsAny(), + /*create*/ It.IsAny>())) + .Returns>( + (key, link, state, create) => + { + cacheSetContext.Setup(c => c.State).Returns(state); + return create(cacheSetContext.Object); + }) + .Verifiable(); + var requestPathBase = PathString.Empty; + var globbingUrlBuilder = new GlobbingUrlBuilder(fileProvider, cache, requestPathBase); + + // Act + var urlList = globbingUrlBuilder.BuildUrlList( + staticUrl: null, + includePattern: "**/*.css", + excludePattern: null); + + // Assert + Assert.Collection(urlList, + url => Assert.Equal("/site.css", url), + url => Assert.Equal("/blank.css", url)); + cacheSetContext.VerifyAll(); + Mock.Get(cache).VerifyAll(); + } + + [Theory] + [InlineData("/")] + [InlineData("\\")] + public void TrimsLeadingSlashFromPatterns(string leadingSlash) + { + // Arrange + var fileProvider = MakeFileProvider(MakeDirectoryContents("site.css", "blank.css")); + IMemoryCache cache = null; + var requestPathBase = PathString.Empty; + var includePatterns = new List(); + var excludePatterns = new List(); + var matcher = MakeMatcher(includePatterns, excludePatterns); + var globbingUrlBuilder = new GlobbingUrlBuilder(fileProvider, cache, requestPathBase); + globbingUrlBuilder.MatcherBuilder = () => matcher; + + // Act + var urlList = globbingUrlBuilder.BuildUrlList( + staticUrl: null, + includePattern: $"{leadingSlash}**/*.css", + excludePattern: $"{leadingSlash}**/*.min.css"); + + // Assert + Assert.Collection(includePatterns, pattern => Assert.Equal("**/*.css", pattern)); + Assert.Collection(excludePatterns, pattern => Assert.Equal("**/*.min.css", pattern)); + } + + [Theory] + [InlineData("/")] + [InlineData("\\")] + public void TrimsOnlySingleLeadingSlashFromPatterns(string leadingSlash) + { + // Arrange + var leadingSlashes = $"{leadingSlash}{leadingSlash}"; + var fileProvider = MakeFileProvider(MakeDirectoryContents("site.css", "blank.css")); + IMemoryCache cache = null; + var requestPathBase = PathString.Empty; + var includePatterns = new List(); + var excludePatterns = new List(); + var matcher = MakeMatcher(includePatterns, excludePatterns); + var globbingUrlBuilder = new GlobbingUrlBuilder(fileProvider, cache, requestPathBase); + globbingUrlBuilder.MatcherBuilder = () => matcher; + + // Act + var urlList = globbingUrlBuilder.BuildUrlList( + staticUrl: null, + includePattern: $"{leadingSlashes}**/*.css", + excludePattern: $"{leadingSlashes}**/*.min.css"); + + // Assert + Assert.Collection(includePatterns, pattern => Assert.Equal($"{leadingSlash}**/*.css", pattern)); + Assert.Collection(excludePatterns, pattern => Assert.Equal($"{leadingSlash}**/*.min.css", pattern)); + } + + private static IFileInfo MakeFileInfo(string name) + { + var fileInfo = new Mock(); + fileInfo.Setup(f => f.Name).Returns(name); + return fileInfo.Object; + } + + private static IDirectoryContents MakeDirectoryContents(params string[] fileNames) + { + var files = fileNames.Select(name => MakeFileInfo(name)); + var directoryContents = new Mock(); + directoryContents.Setup(dc => dc.GetEnumerator()).Returns(files.GetEnumerator()); + + return directoryContents.Object; + } + + private static IFileProvider MakeFileProvider(IDirectoryContents directoryContents = null) + { + if (directoryContents == null) + { + directoryContents = MakeDirectoryContents(); + } + + var fileProvider = new Mock(); + fileProvider.Setup(fp => fp.GetDirectoryContents(It.IsAny())) + .Returns(directoryContents); + return fileProvider.Object; + } + + private static IMemoryCache MakeCache(object result = null) + { + var cache = new Mock(); + cache.Setup(c => c.TryGetValue(It.IsAny(), It.IsAny(), out result)) + .Returns(result != null); + return cache.Object; + } + + private static Matcher MakeMatcher(List includePatterns, List excludePatterns) + { + var matcher = new Mock(); + matcher.Setup(m => m.AddInclude(It.IsAny())) + .Returns(pattern => + { + includePatterns.Add(pattern); + return matcher.Object; + }); + matcher.Setup(m => m.AddExclude(It.IsAny())) + .Returns(pattern => + { + excludePatterns.Add(pattern); + return matcher.Object; + }); + var patternMatchingResult = new PatternMatchingResult(Enumerable.Empty()); + matcher.Setup(m => m.Execute(It.IsAny())).Returns(patternMatchingResult); + return matcher.Object; + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/JavaScriptResourcesTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/JavaScriptResourcesTest.cs new file mode 100644 index 0000000000..7c8a640788 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/JavaScriptResourcesTest.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Text; +using Xunit; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + public class JavaScriptResourcesTest + { + [Fact] + public void GetEmbeddedJavaScript_LoadsEmbeddedResourceFromManifestStream() + { + // Arrange + var resource = "window.alert('An alert');"; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(resource)); + var getManifestResourceStream = new Func(name => stream); + var cache = new ConcurrentDictionary(); + + // Act + var result = JavaScriptResources.GetEmbeddedJavaScript("test.js", getManifestResourceStream, cache); + + // Assert + Assert.Equal(resource, result); + } + + [Fact] + public void GetEmbeddedJavaScript_AddsResourceToCacheWhenRead() + { + // Arrange + var resource = "window.alert('An alert');"; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(resource)); + var getManifestResourceStream = new Func(name => stream); + var cache = new ConcurrentDictionary(); + + // Act + var result = JavaScriptResources.GetEmbeddedJavaScript("test.js", getManifestResourceStream, cache); + + // Assert + Assert.Collection(cache, kvp => + { + Assert.Equal("test.js", kvp.Key); + Assert.Equal(resource, kvp.Value); + }); + } + + [Fact] + public void GetEmbeddedJavaScript_LoadsResourceFromCacheAfterInitialCall() + { + // Arrange + var resource = "window.alert('An alert');"; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(resource)); + var callCount = 0; + var getManifestResourceStream = new Func(name => + { + callCount++; + return stream; + }); + var cache = new ConcurrentDictionary(); + + // Act + var result = JavaScriptResources.GetEmbeddedJavaScript("test.js", getManifestResourceStream, cache); + result = JavaScriptResources.GetEmbeddedJavaScript("test.js", getManifestResourceStream, cache); + + // Assert + Assert.Equal(1, callCount); + } + + [Theory] + [InlineData("window.alert(\"[[[0]]]\")", "window.alert(\"{0}\")")] + [InlineData("var test = { a: 1 };", "var test = {{ a: 1 }};")] + [InlineData("var test = { a: 1, b: \"[[[0]]]\" };", "var test = {{ a: 1, b: \"{0}\" }};")] + public void GetEmbeddedJavaScript_PreparesJavaScriptCorrectly(string resource, string expectedResult) + { + // Arrange + var stream = new MemoryStream(Encoding.UTF8.GetBytes(resource)); + var getManifestResourceStream = new Func(name => stream); + var cache = new ConcurrentDictionary(); + + // Act + var result = JavaScriptResources.GetEmbeddedJavaScript("test.js", getManifestResourceStream, cache); + + // Assert + Assert.Equal(expectedResult, result); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/ModeMatchResultTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/ModeMatchResultTest.cs new file mode 100644 index 0000000000..042a555429 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/ModeMatchResultTest.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Microsoft.Framework.Logging; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.TagHelpers.Internal +{ + public class ModeMatchResultTest + { + [Fact] + public void LogDetails_LogsVerboseWhenNoFullMatchesFound() + { + // Arrange + var modeMatchResult = new ModeMatchResult(); + var logger = MakeLogger(LogLevel.Verbose); + var tagHelper = new Mock(); + var uniqueId = "id"; + + // Act + modeMatchResult.LogDetails(logger, tagHelper.Object, uniqueId); + + // Assert + Mock.Get(logger).Verify(l => l.Write( + LogLevel.Verbose, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Once); + } + + [Fact] + public void LogDetails_DoesNotLogWhenPartialMatchFoundButNoPartiallyMatchedAttributesFound() + { + // Arrange + var modeMatchResult = new ModeMatchResult(); + modeMatchResult.FullMatches.Add( + ModeMatchAttributes.Create("mode0", new[] { "first-attr" })); + modeMatchResult.PartialMatches.Add( + ModeMatchAttributes.Create("mode1", new[] { "first-attr" }, new[] { "second-attr" })); + var logger = MakeLogger(LogLevel.Verbose); + var tagHelper = new Mock(); + var uniqueId = "id"; + + // Act + modeMatchResult.LogDetails(logger, tagHelper.Object, uniqueId); + + // Assert + Mock.Get(logger).Verify(l => l.Write( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Never); + Mock.Get(logger).Verify(l => l.Write( + LogLevel.Verbose, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Never); + } + + [Fact] + public void LogDetails_LogsWhenPartiallyMatchedAttributesFound() + { + // Arrange + var modeMatchResult = new ModeMatchResult(); + modeMatchResult.PartialMatches.Add( + ModeMatchAttributes.Create("mode0", new[] { "first-attr" }, new[] { "second-attr" })); + modeMatchResult.PartiallyMatchedAttributes.Add("first-attr"); + var logger = MakeLogger(LogLevel.Verbose); + var tagHelper = new Mock(); + var uniqueId = "id"; + + // Act + modeMatchResult.LogDetails(logger, tagHelper.Object, uniqueId); + + // Assert + Mock.Get(logger).Verify(l => l.Write( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Once); + Mock.Get(logger).Verify(l => l.Write( + LogLevel.Verbose, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Once); + } + + [Fact] + public void LogDetails_DoesNotLogWhenLoggingLevelIsSetAboveWarning() + { + // Arrange + var modeMatchResult = new ModeMatchResult(); + modeMatchResult.PartialMatches.Add( + ModeMatchAttributes.Create("mode0", new[] { "first-attr" }, new[] { "second-attr" })); + modeMatchResult.PartiallyMatchedAttributes.Add("first-attr"); + var logger = MakeLogger(LogLevel.Critical); + var tagHelper = new Mock(); + var uniqueId = "id"; + + // Act + modeMatchResult.LogDetails(logger, tagHelper.Object, uniqueId); + + // Assert + Mock.Get(logger).Verify(l => l.Write( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Never); + Mock.Get(logger).Verify(l => l.Write( + LogLevel.Verbose, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Never); + } + + private static ILogger MakeLogger(LogLevel level) + { + var logger = new Mock(); + logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(l => l >= level); + + return logger.Object; + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/JavaScriptUtilityTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/JavaScriptUtilityTest.cs deleted file mode 100644 index 8b417156e7..0000000000 --- a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/JavaScriptUtilityTest.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using Xunit; - -namespace Microsoft.AspNet.Mvc.TagHelpers.Test -{ - public class JavaScriptUtilityTest - { - [Theory] - [InlineData("Hello World", "Hello World")] - [InlineData("Hello & World", "Hello \\u0026 World")] - [InlineData("Hello \r World", "Hello \\r World")] - [InlineData("Hello \n World", "Hello \\n World")] - [InlineData("Hello < World", "Hello \\u003c World")] - [InlineData("Hello > World", "Hello \\u003e World")] - [InlineData("Hello ' World", "Hello \\u0027 World")] - [InlineData("Hello \" World", "Hello \\u0022 World")] - [InlineData("Hello \\ World", "Hello \\\\ World")] - [InlineData("Hello \u0005 \u001f World", "Hello \\u0005 \\u001f World")] - [InlineData("Hello \r\n 'eep' & \"hey\" World", "Hello \\r\\n \\u003cah /\\u003e \\u0027eep\\u0027 \\u0026 \\u0022hey\\u0022 World")] - public void JavaScriptEncode_EncodesCorrectly(string input, string expectedOutput) - { - // Act - var result = JavaScriptUtility.JavaScriptStringEncode(input); - - // Assert - Assert.Equal(expectedOutput, result); - } - - [Theory] - [InlineData("window.alert(\"[[[0]]]\")", "window.alert(\"{0}\")")] - [InlineData("var test = { a: 1 };", "var test = {{ a: 1 }};")] - [InlineData("var test = { a: 1, b: \"[[[0]]]\" };", "var test = {{ a: 1, b: \"{0}\" }};")] - public void PrepareFormatString_PreparesJavaScriptCorrectly(string input, string expectedOutput) - { - // Act - var result = JavaScriptUtility.PrepareFormatString(input); - - // Assert - Assert.Equal(expectedOutput, result); - } - } -} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/LinkTagHelperTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/LinkTagHelperTest.cs index 0a9999a49c..76d481bd0f 100644 --- a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/LinkTagHelperTest.cs +++ b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/LinkTagHelperTest.cs @@ -3,8 +3,17 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNet.FileProviders; +using Microsoft.AspNet.Hosting; +using Microsoft.AspNet.Http.Core; +using Microsoft.AspNet.Mvc.ModelBinding; +using Microsoft.AspNet.Mvc.Rendering; +using Microsoft.AspNet.Mvc.TagHelpers.Internal; using Microsoft.AspNet.Razor.Runtime.TagHelpers; +using Microsoft.AspNet.Routing; using Microsoft.Framework.Logging; using Moq; using Xunit; @@ -13,30 +22,91 @@ namespace Microsoft.AspNet.Mvc.TagHelpers { public class LinkTagHelperTest { - [Fact] - public void RunsWhenRequiredAttributesArePresent() + public static TheoryData RunsWhenRequiredAttributesArePresent_Data + { + get + { + return new TheoryData, Action> + { + { + new Dictionary + { + ["asp-href-include"] = "*.css" + }, + tagHelper => + { + tagHelper.HrefInclude = "*.css"; + } + }, + { + new Dictionary + { + ["asp-href-include"] = "*.css", + ["asp-href-exclude"] = "*.min.css" + }, + tagHelper => + { + tagHelper.HrefInclude = "*.css"; + tagHelper.HrefExclude = "*.min.css"; + } + }, + { + new Dictionary + { + ["asp-fallback-href"] = "test.css", + ["asp-fallback-test-class"] = "hidden", + ["asp-fallback-test-property"] = "visibility", + ["asp-fallback-test-value"] = "hidden" + }, + tagHelper => + { + tagHelper.FallbackHref = "test.css"; + tagHelper.FallbackTestClass = "hidden"; + tagHelper.FallbackTestProperty = "visibility"; + tagHelper.FallbackTestValue = "hidden"; + } + }, + { + new Dictionary + { + ["asp-fallback-href-include"] = "*.css", + ["asp-fallback-test-class"] = "hidden", + ["asp-fallback-test-property"] = "visibility", + ["asp-fallback-test-value"] = "hidden" + }, + tagHelper => + { + tagHelper.FallbackHrefInclude = "*.css"; + tagHelper.FallbackTestClass = "hidden"; + tagHelper.FallbackTestProperty = "visibility"; + tagHelper.FallbackTestValue = "hidden"; + } + } + }; + } + } + + [Theory] + [MemberData(nameof(RunsWhenRequiredAttributesArePresent_Data))] + public void RunsWhenRequiredAttributesArePresent( + IDictionary attributes, + Action setProperties) { // Arrange - var context = MakeTagHelperContext( - attributes: new Dictionary - { - { "asp-fallback-href", "test.css" }, - { "asp-fallback-test-class", "hidden" }, - { "asp-fallback-test-property", "visible" }, - { "asp-fallback-test-value", "hidden" }, - }); + var context = MakeTagHelperContext(attributes); var output = MakeTagHelperOutput("link"); var logger = new Mock>(); - - // Act + var hostingEnvironment = MakeHostingEnvironment(); + var viewContext = MakeViewContext(); var helper = new LinkTagHelper { Logger = logger.Object, - FallbackHref = "test.css", - FallbackTestClass = "hidden", - FallbackTestProperty = "visible", - FallbackTestValue = "hidden" + HostingEnvironment = hostingEnvironment, + ViewContext = viewContext, }; + setProperties(helper); + + // Act helper.Process(context, output); // Assert @@ -52,62 +122,135 @@ namespace Microsoft.AspNet.Mvc.TagHelpers var context = MakeTagHelperContext( attributes: new Dictionary { - { "rel", "stylesheet"}, - { "data-extra", "something"}, - { "href", "test.css"}, - { "asp-fallback-href", "test.css" }, - { "asp-fallback-test-class", "hidden" }, - { "asp-fallback-test-property", "visible" }, - { "asp-fallback-test-value", "hidden" } + ["rel"] = "stylesheet", + ["data-extra"] = "something", + ["href"] = "test.css", + ["asp-fallback-href"] = "test.css", + ["asp-fallback-test-class"] = "hidden", + ["asp-fallback-test-property"] = "visibility", + ["asp-fallback-test-value"] = "hidden" }); var output = MakeTagHelperOutput("link", attributes: new Dictionary { - { "rel", "stylesheet"}, - { "data-extra", "something"}, - { "href", "test.css"} + ["rel"] = "stylesheet", + ["data-extra"] = "something", + ["href"] = "test.css" }); var logger = new Mock>(); - - // Act + var hostingEnvironment = MakeHostingEnvironment(); + var viewContext = MakeViewContext(); var helper = new LinkTagHelper { Logger = logger.Object, + HostingEnvironment = hostingEnvironment, + ViewContext = viewContext, FallbackHref = "test.css", FallbackTestClass = "hidden", - FallbackTestProperty = "visible", + FallbackTestProperty = "visibility", FallbackTestValue = "hidden" }; + + // Act helper.Process(context, output); // Assert Assert.StartsWith(", Action> + { + { + new Dictionary + { + // This is commented out on purpose: ["asp-href-include"] = "*.css", + ["asp-href-exclude"] = "*.min.css" + }, + tagHelper => + { + // This is commented out on purpose: tagHelper.HrefInclude = "*.css"; + tagHelper.HrefExclude = "*.min.css"; + } + }, + { + new Dictionary + { + // This is commented out on purpose: ["asp-fallback-href"] = "test.css", + ["asp-fallback-test-class"] = "hidden", + ["asp-fallback-test-property"] = "visibility", + ["asp-fallback-test-value"] = "hidden" + }, + tagHelper => + { + // This is commented out on purpose: tagHelper.FallbackHref = "test.css"; + tagHelper.FallbackTestClass = "hidden"; + tagHelper.FallbackTestProperty = "visibility"; + tagHelper.FallbackTestValue = "hidden"; + } + }, + { + new Dictionary + { + ["asp-fallback-href"] = "test.css", + ["asp-fallback-test-class"] = "hidden", + // This is commented out on purpose: ["asp-fallback-test-property"] = "visibility", + ["asp-fallback-test-value"] = "hidden" + }, + tagHelper => + { + tagHelper.FallbackHref = "test.css"; + tagHelper.FallbackTestClass = "hidden"; + // This is commented out on purpose: tagHelper.FallbackTestProperty = "visibility"; + tagHelper.FallbackTestValue = "hidden"; + } + }, + { + new Dictionary + { + // This is commented out on purpose: ["asp-fallback-href-include"] = "test.css", + ["asp-fallback-href-exclude"] = "**/*.min.css", + ["asp-fallback-test-class"] = "hidden", + ["asp-fallback-test-property"] = "visibility", + ["asp-fallback-test-value"] = "hidden" + }, + tagHelper => + { + // This is commented out on purpose: tagHelper.FallbackHrefInclude = "test.css"; + tagHelper.FallbackHrefExclude = "**/*.min.css"; + tagHelper.FallbackTestClass = "hidden"; + tagHelper.FallbackTestProperty = "visibility"; + tagHelper.FallbackTestValue = "hidden"; + } + } + }; + } + } + + [Theory] + [MemberData(nameof(DoesNotRunWhenARequiredAttributeIsMissing_Data))] + public void DoesNotRunWhenARequiredAttributeIsMissing( + IDictionary attributes, + Action setProperties) { // Arrange - var context = MakeTagHelperContext( - attributes: new Dictionary - { - // This is commented out on purpose: { "asp-fallback-href", "test.css" }, - { "asp-fallback-test-class", "hidden" }, - { "asp-fallback-test-property", "visible" }, - { "asp-fallback-test-value", "hidden" }, - }); + var context = MakeTagHelperContext(attributes); var output = MakeTagHelperOutput("link"); var logger = new Mock>(); - - // Act + var hostingEnvironment = MakeHostingEnvironment(); + var viewContext = MakeViewContext(); var helper = new LinkTagHelper { Logger = logger.Object, - // This is commented out on purpose: FallbackHref = "test.css", - FallbackTestClass = "hidden", - FallbackTestProperty = "visible", - FallbackTestValue = "hidden" + HostingEnvironment = hostingEnvironment, + ViewContext = viewContext }; + setProperties(helper); + + // Act helper.Process(context, output); // Assert @@ -122,12 +265,16 @@ namespace Microsoft.AspNet.Mvc.TagHelpers var context = MakeTagHelperContext(); var output = MakeTagHelperOutput("link"); var logger = new Mock>(); - - // Act + var hostingEnvironment = MakeHostingEnvironment(); + var viewContext = MakeViewContext(); var helper = new LinkTagHelper { - Logger = logger.Object + Logger = logger.Object, + HostingEnvironment = hostingEnvironment, + ViewContext = viewContext }; + + // Act helper.Process(context, output); // Assert @@ -135,7 +282,56 @@ namespace Microsoft.AspNet.Mvc.TagHelpers Assert.False(output.ContentSet); } - private TagHelperContext MakeTagHelperContext( + [Fact] + public void RendersLinkTagsForGlobbedHrefResults() + { + // Arrange + var context = MakeTagHelperContext( + attributes: new Dictionary + { + ["href"] = "/css/site.css", + ["rel"] = "stylesheet", + ["asp-href-include"] = "**/*.css" + }); + var output = MakeTagHelperOutput("link", attributes: new Dictionary + { + ["href"] = "/css/site.css", + ["rel"] = "stylesheet" + }); + var logger = new Mock>(); + var hostingEnvironment = MakeHostingEnvironment(); + var viewContext = MakeViewContext(); + var globbingUrlBuilder = new Mock(); + globbingUrlBuilder.Setup(g => g.BuildUrlList("/css/site.css", "**/*.css", null)) + .Returns(new[] { "/css/site.css", "/base.css" }); + var helper = new LinkTagHelper + { + GlobbingUrlBuilder = globbingUrlBuilder.Object, + Logger = logger.Object, + HostingEnvironment = hostingEnvironment, + ViewContext = viewContext, + HrefInclude = "**/*.css" + }; + + // Act + helper.Process(context, output); + + // Assert + Assert.Equal("" + + "", output.Content); + } + + private static ViewContext MakeViewContext() + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + var metadataProvider = new EmptyModelMetadataProvider(); + var viewData = new ViewDataDictionary(metadataProvider); + var viewContext = new ViewContext(actionContext, Mock.Of(), viewData, TextWriter.Null); + + return viewContext; + } + + private static TagHelperContext MakeTagHelperContext( IDictionary attributes = null, string content = null) { @@ -144,11 +340,29 @@ namespace Microsoft.AspNet.Mvc.TagHelpers return new TagHelperContext(attributes, Guid.NewGuid().ToString("N"), () => Task.FromResult(content)); } - private TagHelperOutput MakeTagHelperOutput(string tagName, IDictionary attributes = null) + private static TagHelperOutput MakeTagHelperOutput(string tagName, IDictionary attributes = null) { attributes = attributes ?? new Dictionary(); - + return new TagHelperOutput(tagName, attributes); } + + private static IHostingEnvironment MakeHostingEnvironment(IFileProvider webRootFileProvider = null) + { + var emptyDirectoryContents = new Mock(); + emptyDirectoryContents.Setup(dc => dc.GetEnumerator()) + .Returns(Enumerable.Empty().GetEnumerator()); + if (webRootFileProvider == null) + { + var mockFileProvider = new Mock(); + mockFileProvider.Setup(fp => fp.GetDirectoryContents(It.IsAny())) + .Returns(emptyDirectoryContents.Object); + webRootFileProvider = mockFileProvider.Object; + } + var hostingEnvironment = new Mock(); + hostingEnvironment.Setup(h => h.WebRootFileProvider).Returns(webRootFileProvider); + + return hostingEnvironment.Object; + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/ScriptTagHelperTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/ScriptTagHelperTest.cs index 2a69df651a..1a4db7a6a8 100644 --- a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/ScriptTagHelperTest.cs +++ b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/ScriptTagHelperTest.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNet.Mvc.TagHelpers.Internal; using Microsoft.AspNet.Razor.Runtime.TagHelpers; using Microsoft.Framework.Logging; using Xunit; diff --git a/test/WebSites/MvcTagHelpersWebSite/Views/MvcTagHelper_Home/Link.cshtml b/test/WebSites/MvcTagHelpersWebSite/Views/MvcTagHelper_Home/Link.cshtml index d1537d9c57..8553cf89b9 100644 --- a/test/WebSites/MvcTagHelpersWebSite/Views/MvcTagHelper_Home/Link.cshtml +++ b/test/WebSites/MvcTagHelpersWebSite/Views/MvcTagHelper_Home/Link.cshtml @@ -5,12 +5,114 @@ Link - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/WebSites/MvcTagHelpersWebSite/appRoot.css b/test/WebSites/MvcTagHelpersWebSite/appRoot.css new file mode 100644 index 0000000000..7b9214822c --- /dev/null +++ b/test/WebSites/MvcTagHelpersWebSite/appRoot.css @@ -0,0 +1,8 @@ +body::after { + display: block; + background-color: #ff0000; + color: #fff; + font-size: 1.2em; + margin-top: 2.4em; + content: "ERROR: Stylesheet 'appRoot.css' was loaded from outside webroot!"; +} \ No newline at end of file diff --git a/test/WebSites/MvcTagHelpersWebSite/wwwroot/site.css b/test/WebSites/MvcTagHelpersWebSite/wwwroot/site.css new file mode 100644 index 0000000000..5c98e1d66e --- /dev/null +++ b/test/WebSites/MvcTagHelpersWebSite/wwwroot/site.css @@ -0,0 +1,6 @@ +body::after { + display: block; + color: #0fa912; + margin-top: 2.4em; + content: "Stylesheet 'site.css' loaded successfully!"; +} \ No newline at end of file diff --git a/test/WebSites/MvcTagHelpersWebSite/wwwroot/sub/site2.css b/test/WebSites/MvcTagHelpersWebSite/wwwroot/sub/site2.css new file mode 100644 index 0000000000..8bbb6ad4d5 --- /dev/null +++ b/test/WebSites/MvcTagHelpersWebSite/wwwroot/sub/site2.css @@ -0,0 +1,6 @@ +body::after { + display: block; + color: #0fa912; + margin-top: 2.4em; + content: "Stylesheet 'site2.css' loaded successfully!"; +} \ No newline at end of file diff --git a/test/WebSites/MvcTagHelpersWebSite/wwwroot/sub/site3.css b/test/WebSites/MvcTagHelpersWebSite/wwwroot/sub/site3.css new file mode 100644 index 0000000000..66c41540aa --- /dev/null +++ b/test/WebSites/MvcTagHelpersWebSite/wwwroot/sub/site3.css @@ -0,0 +1,7 @@ +body::after { + display: block; + background-color: #ff0000; + color: #fff; + margin-top: 2.4em; + content: "ERROR: Stylesheet 'site3.css' was loaded despite being excluded!"; +} \ No newline at end of file