Add globbing support to the LinkTagHelper:

- #1581
This commit is contained in:
damianedwards 2015-02-16 18:16:52 -08:00
parent bcbbc58515
commit 6e845f0171
38 changed files with 2132 additions and 335 deletions

View File

@ -1,12 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<link href="~/blank.css" rel="stylesheet" />
<link href="~/site.min.css" rel="stylesheet"
asp-fallback-href="~/site.css"
<link href="~/css/sub/blank.css" asp-href-include="**/blank.css" rel="stylesheet" data-test="value" />
<link href="site.min.css" rel="stylesheet" data-test="value"
asp-fallback-href-include="**/site.css"
asp-fallback-test-class="fallback-test"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden" />
<link asp-href-include="../*.css" rel="stylesheet" />
</head>
<body>
@RenderBody()

View File

@ -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!";
}

View File

@ -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": {

View File

@ -0,0 +1 @@
body {}

View File

@ -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
{
/// <summary>
/// Methods for determining how an <see cref="ITagHelper"/> should run based on the attributes that were specified.
/// </summary>
public static class AttributeMatcher
{
/// <summary>
/// Determines whether a <see cref="ITagHelper" />'s required attributes are present, non null, non empty, and
/// non whitepsace.
/// </summary>
/// <param name="context">The <see cref="TagHelperContext"/>.</param>
/// <param name="requiredAttributes">
/// The attributes the <see cref="ITagHelper" /> requires in order to run.
/// </param>
/// <param name="logger">An optional <see cref="ILogger"/> to log warning details to.</param>
/// <returns>A <see cref="bool"/> indicating whether the <see cref="ITagHelper" /> should run.</returns>
public static bool AllRequiredAttributesArePresent(
[NotNull] TagHelperContext context,
[NotNull] IEnumerable<string> 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;
}
/// <summary>
/// Determines the modes a <see cref="ITagHelper" /> can run in based on which modes have all their required
/// attributes present, non null, non empty, and non whitepsace.
/// </summary>
/// <typeparam name="TMode">The type representing the <see cref="ITagHelper" />'s modes.</typeparam>
/// <param name="context">The <see cref="TagHelperContext"/>.</param>
/// <param name="modeInfos">The modes and their required attributes.</param>
/// <returns>The <see cref="ModeMatchResult{TMode}"/>.</returns>
public static ModeMatchResult<TMode> DetermineMode<TMode>(
[NotNull] TagHelperContext context,
[NotNull] IEnumerable<ModeAttributes<TMode>> modeInfos)
{
// true == full match, false == partial match
var matchedAttributes = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
var result = new ModeMatchResult<TMode>();
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<string> requiredAttributes)
{
// Check for all attribute values
var presentAttributes = new List<string>();
var missingAttributes = new List<string>();
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<string> Present { get; set; }
public IEnumerable<string> Missing { get; set; }
}
}
}

View File

@ -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<FileSystemInfoBase> 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);
}
}
}

View File

@ -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; }
}
}

View File

@ -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
{
/// <summary>
/// Utility methods for <see cref="ITagHelper"/>'s that support attributes containing file globbing patterns.
/// </summary>
public class GlobbingUrlBuilder
{
private static readonly char[] PatternSeparator = new[] { ',' };
private readonly FileProviderGlobbingDirectory _baseGlobbingDirectory;
// Internal for testing
internal GlobbingUrlBuilder() { }
/// <summary>
/// Creates a new <see cref="GlobbingUrlBuilder"/>.
/// </summary>
/// <param name="fileProvider">The file provider.</param>
/// <param name="cache">The cache.</param>
/// <param name="requestPathBase">The request path base.</param>
public GlobbingUrlBuilder([NotNull] IFileProvider fileProvider, IMemoryCache cache, PathString requestPathBase)
{
FileProvider = fileProvider;
Cache = cache;
RequestPathBase = requestPathBase;
_baseGlobbingDirectory = new FileProviderGlobbingDirectory(fileProvider, fileInfo: null, parent: null);
}
/// <summary>
/// The <see cref="IMemoryCache"/> to cache globbing results in.
/// </summary>
public IMemoryCache Cache { get; }
/// <summary>
/// The <see cref="IFileProvider"/> used to watch for changes to file globbing results.
/// </summary>
public IFileProvider FileProvider { get; }
/// <summary>
/// The base path of the current request (i.e. <see cref="HttpRequest.PathBase"/>).
/// </summary>
public PathString RequestPathBase { get; }
// Internal for testing.
internal Func<Matcher> MatcherBuilder { get; set; }
/// <summary>
/// Builds a list of URLs.
/// </summary>
/// <param name="staticUrl">The statically declared URL. This will always be added to the result.</param>
/// <param name="includePattern">The file globbing include pattern.</param>
/// <param name="excludePattern">The file globbing exclude pattern.</param>
/// <returns>The list of URLs</returns>
public virtual IEnumerable<string> BuildUrlList(string staticUrl, string includePattern, string excludePattern)
{
var urls = new HashSet<string>(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<string> ExpandGlobbedUrl(string include, string exclude)
{
if (string.IsNullOrEmpty(include))
{
return Enumerable.Empty<string>();
}
var includePatterns = include.Split(PatternSeparator, StringSplitOptions.RemoveEmptyEntries);
var excludePatterns = exclude?.Split(PatternSeparator, StringSplitOptions.RemoveEmptyEntries);
if (includePatterns.Length == 0)
{
return Enumerable.Empty<string>();
}
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<string> FindFiles(IEnumerable<string> includePatterns, IEnumerable<string> 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;
}
}
}

View File

@ -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
{
/// <summary>
/// Methods for loading JavaScript from assembly embedded resources.
/// </summary>
public static class JavaScriptResources
{
private static readonly Assembly ResourcesAssembly = typeof(JavaScriptResources).GetTypeInfo().Assembly;
private static readonly ConcurrentDictionary<string, string> Cache =
new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
/// <summary>
/// Gets an embedded JavaScript file resource and decodes it for use as a .NET format string.
/// </summary>
public static string GetEmbeddedJavaScript(string resourceName)
{
return GetEmbeddedJavaScript(resourceName, ResourcesAssembly.GetManifestResourceStream, Cache);
}
// Internal for testing
internal static string GetEmbeddedJavaScript(
string resourceName,
Func<string, Stream> getManifestResourceStream,
ConcurrentDictionary<string, string> 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("]]]", "}");
}
}
}

View File

@ -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
{
/// <summary>
/// Methods for encoding <see cref="IEnumerable{String}"/> for use as a JavaScript array literal.
/// </summary>
public static class JavaScriptStringArrayEncoder
{
/// <summary>
/// Encodes a .NET string array for safe use as a JavaScript array literal, including inline in an HTML file.
/// </summary>
public static string Encode(IJavaScriptStringEncoder encoder, IEnumerable<string> 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();
}
}
}

View File

@ -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
{
/// <summary>
/// An <see cref="ILoggerStructure"/> for log messages regarding <see cref="TagHelper"/> instances that opt out of
/// An <see cref="ILoggerStructure"/> for log messages regarding <see cref="ITagHelper"/> instances that opt out of
/// processing due to missing required attributes.
/// </summary>
public class MissingAttributeLoggerStructure : ILoggerStructure
@ -16,7 +16,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
private readonly string _uniqueId;
private readonly IEnumerable<KeyValuePair<string, object>> _values;
// internal for unit testing.
// Internal for unit testing
internal IEnumerable<string> MissingAttributes { get; }
/// <summary>
@ -30,8 +30,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
MissingAttributes = missingAttributes;
_values = new Dictionary<string, object>
{
{ "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.";
}
}

View File

@ -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
{
/// <summary>
/// Static creation methods for <see cref="ModeAttributes{TMode}"/>.
/// </summary>
public static class ModeAttributes
{
/// <summary>
/// Creates an <see cref="ModeAttributes{TMode}"/>/
/// </summary>
public static ModeAttributes<TMode> Create<TMode>(TMode mode, IEnumerable<string> attributes)
{
return new ModeAttributes<TMode>
{
Mode = mode,
Attributes = attributes
};
}
}
}

View File

@ -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
{
/// <summary>
/// A mapping of a <see cref="ITagHelper"/> mode to its required attributes.
/// </summary>
/// <typeparam name="TMode">The type representing the <see cref="ITagHelper"/>'s mode.</typeparam>
public class ModeAttributes<TMode>
{
/// <summary>
/// The <see cref="ITagHelper"/>'s mode.
/// </summary>
public TMode Mode { get; set; }
/// <summary>
/// The names of attributes required for this mode.
/// </summary>
public IEnumerable<string> Attributes { get; set; }
}
}

View File

@ -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
{
/// <summary>
/// Static creation methods for <see cref="ModeMatchAttributes{TMode}"/>.
/// </summary>
public static class ModeMatchAttributes
{
/// <summary>
/// Creates an <see cref="ModeMatchAttributes{TMode}"/>.
/// </summary>
public static ModeMatchAttributes<TMode> Create<TMode>(
TMode mode,
IEnumerable<string> presentAttributes)
{
return Create(mode, presentAttributes, missingAttributes: null);
}
/// <summary>
/// Creates an <see cref="ModeMatchAttributes{TMode}"/>.
/// </summary>
public static ModeMatchAttributes<TMode> Create<TMode>(
TMode mode,
IEnumerable<string> presentAttributes,
IEnumerable<string> missingAttributes)
{
return new ModeMatchAttributes<TMode>
{
Mode = mode,
PresentAttributes = presentAttributes,
MissingAttributes = missingAttributes
};
}
}
}

View File

@ -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
{
/// <summary>
/// A mapping of a <see cref="ITagHelper"/> mode to its missing and present attributes.
/// </summary>
/// <typeparam name="TMode">The type representing the <see cref="ITagHelper"/>'s mode.</typeparam>
public class ModeMatchAttributes<TMode>
{
/// <summary>
/// The <see cref="ITagHelper"/>'s mode.
/// </summary>
public TMode Mode { get; set; }
/// <summary>
/// The names of attributes that were present in this match.
/// </summary>
public IEnumerable<string> PresentAttributes { get; set; }
/// <summary>
/// The names of attributes that were missing in this match.
/// </summary>
public IEnumerable<string> MissingAttributes { get; set; }
}
}

View File

@ -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
{
/// <summary>
/// Result of determining the mode an <see cref="ITagHelper"/> will run in.
/// </summary>
/// <typeparam name="TMode">The type representing the <see cref="ITagHelper"/>'s mode.</typeparam>
public class ModeMatchResult<TMode>
{
/// <summary>
/// Modes that were missing attributes but had at least one attribute present.
/// </summary>
public IList<ModeMatchAttributes<TMode>> PartialMatches { get; } = new List<ModeMatchAttributes<TMode>>();
/// <summary>
/// Modes that had all attributes present.
/// </summary>
public IList<ModeMatchAttributes<TMode>> FullMatches { get; } = new List<ModeMatchAttributes<TMode>>();
/// <summary>
/// Attributes that are present in at least one mode in <see cref="PartialMatches"/>, but in no modes in
/// <see cref="FullMatches"/>.
/// </summary>
public IList<string> PartiallyMatchedAttributes { get; } = new List<string>();
/// <summary>
/// Logs the details of the <see cref="ModeMatchResult{TMode}"/>.
/// </summary>
/// <param name="logger">The <see cref="ILogger"/>.</param>
/// <param name="tagHelper">The <see cref="ITagHelper"/>.</param>
/// <param name="uniqueId">The value of <see cref="TagHelperContext.UniqueId"/>.</param>
public void LogDetails<TTagHelper>([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<TMode>(uniqueId, partialOnlyMatches));
}
if (logger.IsEnabled(LogLevel.Verbose) && !FullMatches.Any())
{
logger.WriteVerbose("Skipping processing for {0} {1}",
tagHelper.GetType().GetTypeInfo().FullName, uniqueId);
}
}
}
}

View File

@ -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
{
/// <summary>
/// An <see cref="ILoggerStructure"/> for log messages regarding <see cref="ITagHelper"/> instances that opt out of
/// processing due to missing attributes for one of several possible modes.
/// </summary>
public class PartialModeMatchLoggerStructure<TMode> : ILoggerStructure
{
private readonly string _uniqueId;
private readonly IEnumerable<ModeMatchAttributes<TMode>> _partialMatches;
private readonly IEnumerable<KeyValuePair<string, object>> _values;
/// <summary>
/// Creates a new <see cref="PartialModeMatchLoggerStructure{TMode}"/>.
/// </summary>
/// <param name="uniqueId">The unique ID of the HTML element this message applies to.</param>
/// <param name="partialMatches">The set of modes with partial required attributes.</param>
public PartialModeMatchLoggerStructure(
string uniqueId,
[NotNull] IEnumerable<ModeMatchAttributes<TMode>> partialMatches)
{
_uniqueId = uniqueId;
_partialMatches = partialMatches;
_values = new Dictionary<string, object>
{
["UniqueId"] = _uniqueId,
["PartialMatches"] = partialMatches
};
}
/// <summary>
/// The log message.
/// </summary>
public string Message
{
get
{
return "Tag Helper has missing required attributes.";
}
}
/// <summary>
/// Gets the values associated with this structured log message.
/// </summary>
/// <returns>The values.</returns>
public IEnumerable<KeyValuePair<string, object>> GetValues()
{
return _values;
}
/// <summary>
/// Generates a human readable string for this structured log message.
/// </summary>
/// <returns>The message.</returns>
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)))));
}
}
}

View File

@ -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
{
/// <summary>
/// Utility methods for dealing with JavaScript.
/// </summary>
public static class JavaScriptUtility
{
private static readonly Assembly ResourcesAssembly = typeof(JavaScriptUtility).GetTypeInfo().Assembly;
private static readonly ConcurrentDictionary<string, string> Cache =
new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
private static readonly IDictionary<char, string> EncodingMap = new Dictionary<char, string>
{
{ '<', @"\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
};
/// <summary>
/// Gets an embedded JavaScript file resource and decodes it for use as a .NET format string.
/// </summary>
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("]]]", "}");
}
/// <summary>
/// Encodes a .NET string for safe use as a JavaScript string literal, including inline in an HTML file.
/// </summary>
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);
}
}
}

View File

@ -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
/// </summary>
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 = "<meta name=\"x-stylesheet-fallback-test\" class=\"{0}\" />";
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<Mode>[] 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
{
/// <summary>
/// Rendering a fallback block if primary stylesheet fails to load. Will also do globbing if the appropriate
/// properties are set.
/// </summary>
Fallback,
/// <summary>
/// Just performing file globbing search for the href, rendering a separate &lt;link&gt; for each match.
/// </summary>
GlobbedHref
}
/// <summary>
/// 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.
/// </summary>
[HtmlAttributeName(HrefIncludeAttributeName)]
public string HrefInclude { get; set; }
/// <summary>
/// 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 <see cref="HrefInclude"/>.
/// </summary>
[HtmlAttributeName(HrefExcludeAttributeName)]
public string HrefExclude { get; set; }
/// <summary>
/// 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; }
/// <summary>
/// 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.
/// </summary>
[HtmlAttributeName(FallbackHrefIncludeAttributeName)]
public string FallbackHrefInclude { get; set; }
/// <summary>
/// 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 <see cref="FallbackHrefInclude"/>.
/// </summary>
[HtmlAttributeName(FallbackHrefExcludeAttributeName)]
public string FallbackHrefExclude { get; set; }
/// <summary>
/// The class name defined in the stylesheet to use for the fallback test.
/// Must be used in conjunction with <see cref="FallbackTestProperty"/> and <see cref="FallbackTestValue"/>,
/// and either <see cref="FallbackHref"/> or <see cref="FallbackHrefInclude"/>.
/// </summary>
[HtmlAttributeName(FallbackTestClassAttributeName)]
public string FallbackTestClass { get; set; }
/// <summary>
/// The CSS property name to use for the fallback test.
/// Must be used in conjunction with <see cref="FallbackTestClass"/> and <see cref="FallbackTestValue"/>,
/// and either <see cref="FallbackHref"/> or <see cref="FallbackHrefInclude"/>.
/// </summary>
[HtmlAttributeName(FallbackTestPropertyAttributeName)]
public string FallbackTestProperty { get; set; }
/// <summary>
/// The CSS property value to use for the fallback test.
/// Must be used in conjunction with <see cref="FallbackTestClass"/> and <see cref="FallbackTestProperty"/>,
/// and either <see cref="FallbackHref"/> or <see cref="FallbackHrefInclude"/>.
/// </summary>
[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<LinkTagHelper> 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; }
/// <inheritdoc />
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<string, string>(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 <link /> tag that loads the primary stylesheet
content.Append("<link ");
foreach (var attribute in output.Attributes)
if (mode == Mode.Fallback && string.IsNullOrEmpty(HrefInclude))
{
content.AppendFormat(CultureInfo.InvariantCulture, "{0}=\"{1}\" ", attribute.Key, attribute.Value);
// No globbing to do, just build a <link /> tag to match the original one in the source file
BuildLinkTag(attributes, builder);
}
else
{
BuildGlobbedLinkTags(attributes, builder);
}
content.AppendLine("/>");
// Build the <meta /> 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 <script /> tag that checks the effective style of <meta /> tag above and renders the extra
// <link /> tag to load the fallback stylesheet if the test CSS property value is found to be false,
// indicating that the primary stylesheet failed to load.
content.Append("<script>");
content.AppendFormat(CultureInfo.InvariantCulture,
JavaScriptUtility.GetEmbeddedJavaScript(FallbackJavaScriptResourceName),
JavaScriptUtility.JavaScriptStringEncode(FallbackTestProperty),
JavaScriptUtility.JavaScriptStringEncode(FallbackTestValue),
JavaScriptUtility.JavaScriptStringEncode(FallbackHref));
content.Append("</script>");
// 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<string, string> attributes, StringBuilder builder)
{
// Build a <link /> 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 <meta /> tag that's used to test for the presence of the stylesheet
builder.AppendFormat(
CultureInfo.InvariantCulture,
"<meta name=\"x-stylesheet-fallback-test\" class=\"{0}\" />",
WebUtility.HtmlEncode(FallbackTestClass));
// Build the <script /> tag that checks the effective style of <meta /> tag above and renders the extra
// <link /> tag to load the fallback stylesheet if the test CSS property value is found to be false,
// indicating that the primary stylesheet failed to load.
builder.Append("<script>")
.AppendFormat(CultureInfo.InvariantCulture,
JavaScriptResources.GetEmbeddedJavaScript(FallbackJavaScriptResourceName),
JavaScriptStringEncoder.Default.JavaScriptStringEncode(FallbackTestProperty),
JavaScriptStringEncoder.Default.JavaScriptStringEncode(FallbackTestValue),
JavaScriptStringArrayEncoder.Encode(JavaScriptStringEncoder.Default, fallbackHrefs))
.Append("</script>");
}
}
private void EnsureGlobbingUrlBuilder()
{
if (GlobbingUrlBuilder == null)
{
GlobbingUrlBuilder = new GlobbingUrlBuilder(
HostingEnvironment.WebRootFileProvider,
Cache,
ViewContext.HttpContext.Request.PathBase);
}
}
private static void BuildLinkTag(IDictionary<string, string> attributes, StringBuilder builder)
{
builder.Append("<link ");
foreach (var attribute in attributes)
{
builder.AppendFormat(CultureInfo.InvariantCulture, "{0}=\"{1}\" ", attribute.Key, attribute.Value);
}
builder.Append("/>");
}
}
}

View File

@ -4,3 +4,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Microsoft.AspNet.Mvc.TagHelpers.Test")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]

View File

@ -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
/// <inheritdoc />
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);
}

View File

@ -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
{
/// <summary>
/// Utility related extensions for <see cref="TagHelperContext"/>.
/// </summary>
public static class TagHelperContextExtensions
{
/// <summary>
/// Determines whether a <see cref="ITagHelper" />'s required attributes are present, non null, non empty, and
/// non whitepsace.
/// </summary>
/// <param name="context">The <see cref="TagHelperContext"/>.</param>
/// <param name="requiredAttributes">The attributes the <see cref="ITagHelper" /> requires in order to run.</param>
/// <param name="logger">An optional <see cref="ILogger"/> to log warning details to.</param>
/// <returns>A <see cref="bool"/> indicating whether the <see cref="ITagHelper" /> should run.</returns>
public static bool AllRequiredAttributesArePresent(
[NotNull]this TagHelperContext context,
[NotNull]IEnumerable<string> requiredAttributes,
ILogger logger = null)
{
// Check for all attribute values & log a warning if any required are missing
var atLeastOnePresent = false;
var missingAttrNames = new List<string>();
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;
}
}
}

View File

@ -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('<link rel="stylesheet" href="' + fallbackHref + '"/>');
for (i = 0; i < fallbackHref.length; i++) {
doc.write('<link rel="stylesheet" href="' + fallbackHref[i] + '"/>');
}
}
})("[[[0]]]", "[[[1]]]", "[[[2]]]");
})("[[[0]]]", "[[[1]]]", [[[2]]]);

View File

@ -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": {

View File

@ -4,10 +4,75 @@
<head>
<meta charset="utf-8" />
<title>Link</title>
<link href="/blank.css" rel="stylesheet"></link>
<link rel="stylesheet" href="/link-test.min.css" data-extra="test" />
<meta name="x-stylesheet-fallback-test" class="hidden" />
<script>!function(a,b,c){var d=document,e=d.getElementsByTagName("SCRIPT"),f=e[e.length-1].previousElementSibling;d.defaultView.getComputedStyle(f)[a]!==b&&d.write('<link rel="stylesheet" href="'+c+'"/>')}("visibility","hidden","/link-test.css");</script>
<!-- Plain link tag -->
<link href="/site.css" rel="stylesheet"></link>
<!-- Globbed link tag with existing file -->
<link rel="stylesheet" href="/site.css" />
<!-- Globbed link tag with existing file and exclude -->
<link rel="stylesheet" href="/site.css" /><link rel="stylesheet" href="/sub/site2.css" />
<!-- Globbed link tag missing include -->
<link rel="stylesheet"></link>
<!-- Globbed link tag missing include but with static href -->
<link href="/site.css" rel="stylesheet"></link>
<!-- Globbed link tag with missing file -->
<!-- Globbed link tag with file outside of webroot -->
<!-- Globbed link tag with file outside of webroot -->
<!-- Globbed link tag with existing file and static href -->
<link href="/site.css" rel="stylesheet" /><link href="/sub/site2.css" rel="stylesheet" />
<!-- Globbed link tag with existing file and static href should dedupe -->
<link href="/site.css" rel="stylesheet" />
<!-- Fallback to static href -->
<link href="/site.min.css" rel="stylesheet" data-extra="test" />
<meta name="x-stylesheet-fallback-test" class="hidden" /><script>!function(a,b,c){var d,e=document,f=e.getElementsByTagName("SCRIPT"),g=f[f.length-1].previousElementSibling;if(e.defaultView.getComputedStyle(g)[a]!==b)for(d=0;d<c.length;d++)e.write('<link rel="stylesheet" href="'+c[d]+'"/>')}("visibility","hidden",["\/site.css"]);</script>
<!-- Fallback to static href with no primary href -->
<link rel="stylesheet" data-extra="test" />
<meta name="x-stylesheet-fallback-test" class="hidden" /><script>!function(a,b,c){var d,e=document,f=e.getElementsByTagName("SCRIPT"),g=f[f.length-1].previousElementSibling;if(e.defaultView.getComputedStyle(g)[a]!==b)for(d=0;d<c.length;d++)e.write('<link rel="stylesheet" href="'+c[d]+'"/>')}("visibility","hidden",["\/site.css"]);</script>
<!-- Fallback to globbed href -->
<link href="/site.min.css" rel="stylesheet" data-extra="test" />
<meta name="x-stylesheet-fallback-test" class="hidden" /><script>!function(a,b,c){var d,e=document,f=e.getElementsByTagName("SCRIPT"),g=f[f.length-1].previousElementSibling;if(e.defaultView.getComputedStyle(g)[a]!==b)for(d=0;d<c.length;d++)e.write('<link rel="stylesheet" href="'+c[d]+'"/>')}("visibility","hidden",["\/site.css"]);</script>
<!-- Fallback to static and globbed href -->
<link href="/site.min.css" rel="stylesheet" data-extra="test" />
<meta name="x-stylesheet-fallback-test" class="hidden" /><script>!function(a,b,c){var d,e=document,f=e.getElementsByTagName("SCRIPT"),g=f[f.length-1].previousElementSibling;if(e.defaultView.getComputedStyle(g)[a]!==b)for(d=0;d<c.length;d++)e.write('<link rel="stylesheet" href="'+c[d]+'"/>')}("visibility","hidden",["\/site.css","\/sub\/site2.css"]);</script>
<!-- Fallback to static and globbed href should dedupe -->
<link href="/site.min.css" rel="stylesheet" data-extra="test" />
<meta name="x-stylesheet-fallback-test" class="hidden" /><script>!function(a,b,c){var d,e=document,f=e.getElementsByTagName("SCRIPT"),g=f[f.length-1].previousElementSibling;if(e.defaultView.getComputedStyle(g)[a]!==b)for(d=0;d<c.length;d++)e.write('<link rel="stylesheet" href="'+c[d]+'"/>')}("visibility","hidden",["\/site.css"]);</script>
<!-- Fallback to static and globbed href with exclude -->
<link href="/site.min.css" rel="stylesheet" data-extra="test" />
<meta name="x-stylesheet-fallback-test" class="hidden" /><script>!function(a,b,c){var d,e=document,f=e.getElementsByTagName("SCRIPT"),g=f[f.length-1].previousElementSibling;if(e.defaultView.getComputedStyle(g)[a]!==b)for(d=0;d<c.length;d++)e.write('<link rel="stylesheet" href="'+c[d]+'"/>')}("visibility","hidden",["\/site.css","\/sub\/site2.css"]);</script>
<!-- Fallback to globbed href that doesn't exist -->
<link href="/site.min.css" rel="stylesheet" data-extra="test" />
<!-- Fallback to globbed href outside webroot -->
<link href="/site.min.css" rel="stylesheet" data-extra="test" />
<!-- Fallback with missing attribute -->
<link href="/site.min.css" rel="stylesheet" data-extra="test"></link>
<!-- Fallback with missing attribute -->
<link href="/site.min.css" rel="stylesheet" data-extra="test"></link>
<!-- Fallback with missing attribute -->
<link href="/site.min.css" rel="stylesheet" data-extra="test"></link>
</head>
<body>

View File

@ -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<string, object>
{
["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<string, object>
{
["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<string, object>
{
["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<string, object> attributes = null,
string content = null)
{
attributes = attributes ?? new Dictionary<string, object>();
return new TagHelperContext(attributes, Guid.NewGuid().ToString("N"), () => Task.FromResult(content));
}
}
}

View File

@ -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<string> { "/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<IExpirationTrigger>();
var fileProvider = MakeFileProvider(MakeDirectoryContents("site.css", "blank.css"));
Mock.Get(fileProvider).Setup(f => f.Watch(It.IsAny<string>())).Returns(trigger.Object);
var cache = MakeCache();
var cacheSetContext = new Mock<ICacheSetContext>();
cacheSetContext.Setup(c => c.AddExpirationTrigger(trigger.Object)).Verifiable();
Mock.Get(cache).Setup(c => c.Set(
/*key*/ It.IsAny<string>(),
/*link*/ It.IsAny<IEntryLink>(),
/*state*/ It.IsAny<object>(),
/*create*/ It.IsAny<Func<ICacheSetContext, object>>()))
.Returns<string, IEntryLink, object, Func<ICacheSetContext, object>>(
(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<string>();
var excludePatterns = new List<string>();
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<string>();
var excludePatterns = new List<string>();
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<IFileInfo>();
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<IDirectoryContents>();
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<IFileProvider>();
fileProvider.Setup(fp => fp.GetDirectoryContents(It.IsAny<string>()))
.Returns(directoryContents);
return fileProvider.Object;
}
private static IMemoryCache MakeCache(object result = null)
{
var cache = new Mock<IMemoryCache>();
cache.Setup(c => c.TryGetValue(It.IsAny<string>(), It.IsAny<IEntryLink>(), out result))
.Returns(result != null);
return cache.Object;
}
private static Matcher MakeMatcher(List<string> includePatterns, List<string> excludePatterns)
{
var matcher = new Mock<Matcher>();
matcher.Setup(m => m.AddInclude(It.IsAny<string>()))
.Returns<string>(pattern =>
{
includePatterns.Add(pattern);
return matcher.Object;
});
matcher.Setup(m => m.AddExclude(It.IsAny<string>()))
.Returns<string>(pattern =>
{
excludePatterns.Add(pattern);
return matcher.Object;
});
var patternMatchingResult = new PatternMatchingResult(Enumerable.Empty<string>());
matcher.Setup(m => m.Execute(It.IsAny<DirectoryInfoBase>())).Returns(patternMatchingResult);
return matcher.Object;
}
}
}

View File

@ -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<string, Stream>(name => stream);
var cache = new ConcurrentDictionary<string, string>();
// 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<string, Stream>(name => stream);
var cache = new ConcurrentDictionary<string, string>();
// 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<string, Stream>(name =>
{
callCount++;
return stream;
});
var cache = new ConcurrentDictionary<string, string>();
// 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<string, Stream>(name => stream);
var cache = new ConcurrentDictionary<string, string>();
// Act
var result = JavaScriptResources.GetEmbeddedJavaScript("test.js", getManifestResourceStream, cache);
// Assert
Assert.Equal(expectedResult, result);
}
}
}

View File

@ -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<string>();
var logger = MakeLogger(LogLevel.Verbose);
var tagHelper = new Mock<ITagHelper>();
var uniqueId = "id";
// Act
modeMatchResult.LogDetails(logger, tagHelper.Object, uniqueId);
// Assert
Mock.Get(logger).Verify(l => l.Write(
LogLevel.Verbose,
It.IsAny<int>(),
It.IsAny<object>(),
It.IsAny<Exception>(),
It.IsAny<Func<object, Exception, string>>()), Times.Once);
}
[Fact]
public void LogDetails_DoesNotLogWhenPartialMatchFoundButNoPartiallyMatchedAttributesFound()
{
// Arrange
var modeMatchResult = new ModeMatchResult<string>();
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<ITagHelper>();
var uniqueId = "id";
// Act
modeMatchResult.LogDetails(logger, tagHelper.Object, uniqueId);
// Assert
Mock.Get(logger).Verify(l => l.Write(
LogLevel.Warning,
It.IsAny<int>(),
It.IsAny<object>(),
It.IsAny<Exception>(),
It.IsAny<Func<object, Exception, string>>()), Times.Never);
Mock.Get(logger).Verify(l => l.Write(
LogLevel.Verbose,
It.IsAny<int>(),
It.IsAny<object>(),
It.IsAny<Exception>(),
It.IsAny<Func<object, Exception, string>>()), Times.Never);
}
[Fact]
public void LogDetails_LogsWhenPartiallyMatchedAttributesFound()
{
// Arrange
var modeMatchResult = new ModeMatchResult<string>();
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<ITagHelper>();
var uniqueId = "id";
// Act
modeMatchResult.LogDetails(logger, tagHelper.Object, uniqueId);
// Assert
Mock.Get(logger).Verify(l => l.Write(
LogLevel.Warning,
It.IsAny<int>(),
It.IsAny<object>(),
It.IsAny<Exception>(),
It.IsAny<Func<object, Exception, string>>()), Times.Once);
Mock.Get(logger).Verify(l => l.Write(
LogLevel.Verbose,
It.IsAny<int>(),
It.IsAny<object>(),
It.IsAny<Exception>(),
It.IsAny<Func<object, Exception, string>>()), Times.Once);
}
[Fact]
public void LogDetails_DoesNotLogWhenLoggingLevelIsSetAboveWarning()
{
// Arrange
var modeMatchResult = new ModeMatchResult<string>();
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<ITagHelper>();
var uniqueId = "id";
// Act
modeMatchResult.LogDetails(logger, tagHelper.Object, uniqueId);
// Assert
Mock.Get(logger).Verify(l => l.Write(
LogLevel.Warning,
It.IsAny<int>(),
It.IsAny<object>(),
It.IsAny<Exception>(),
It.IsAny<Func<object, Exception, string>>()), Times.Never);
Mock.Get(logger).Verify(l => l.Write(
LogLevel.Verbose,
It.IsAny<int>(),
It.IsAny<object>(),
It.IsAny<Exception>(),
It.IsAny<Func<object, Exception, string>>()), Times.Never);
}
private static ILogger MakeLogger(LogLevel level)
{
var logger = new Mock<ILogger>();
logger.Setup(l => l.IsEnabled(It.IsAny<LogLevel>())).Returns<LogLevel>(l => l >= level);
return logger.Object;
}
}
}

View File

@ -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 <ah /> '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);
}
}
}

View File

@ -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<IDictionary<string, object>, Action<LinkTagHelper>>
{
{
new Dictionary<string, object>
{
["asp-href-include"] = "*.css"
},
tagHelper =>
{
tagHelper.HrefInclude = "*.css";
}
},
{
new Dictionary<string, object>
{
["asp-href-include"] = "*.css",
["asp-href-exclude"] = "*.min.css"
},
tagHelper =>
{
tagHelper.HrefInclude = "*.css";
tagHelper.HrefExclude = "*.min.css";
}
},
{
new Dictionary<string, object>
{
["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<string, object>
{
["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<string, object> attributes,
Action<LinkTagHelper> setProperties)
{
// Arrange
var context = MakeTagHelperContext(
attributes: new Dictionary<string, object>
{
{ "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<ILogger<LinkTagHelper>>();
// 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<string, object>
{
{ "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<string, string>
{
{ "rel", "stylesheet"},
{ "data-extra", "something"},
{ "href", "test.css"}
["rel"] = "stylesheet",
["data-extra"] = "something",
["href"] = "test.css"
});
var logger = new Mock<ILogger<LinkTagHelper>>();
// 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("<link rel=\"stylesheet\" data-extra=\"something\" href=\"test.css\"", output.Content);
}
[Fact]
public void DoesNotRunWhenARequiredAttributeIsMissing()
public static TheoryData DoesNotRunWhenARequiredAttributeIsMissing_Data
{
get
{
return new TheoryData<IDictionary<string, object>, Action<LinkTagHelper>>
{
{
new Dictionary<string, object>
{
// 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<string, object>
{
// 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<string, object>
{
["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<string, object>
{
// 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<string, object> attributes,
Action<LinkTagHelper> setProperties)
{
// Arrange
var context = MakeTagHelperContext(
attributes: new Dictionary<string, object>
{
// 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<ILogger<LinkTagHelper>>();
// 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<ILogger<LinkTagHelper>>();
// 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<string, object>
{
["href"] = "/css/site.css",
["rel"] = "stylesheet",
["asp-href-include"] = "**/*.css"
});
var output = MakeTagHelperOutput("link", attributes: new Dictionary<string, string>
{
["href"] = "/css/site.css",
["rel"] = "stylesheet"
});
var logger = new Mock<ILogger<LinkTagHelper>>();
var hostingEnvironment = MakeHostingEnvironment();
var viewContext = MakeViewContext();
var globbingUrlBuilder = new Mock<GlobbingUrlBuilder>();
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("<link href=\"/css/site.css\" rel=\"stylesheet\" />" +
"<link href=\"/base.css\" rel=\"stylesheet\" />", 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<IView>(), viewData, TextWriter.Null);
return viewContext;
}
private static TagHelperContext MakeTagHelperContext(
IDictionary<string, object> 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<string, string> attributes = null)
private static TagHelperOutput MakeTagHelperOutput(string tagName, IDictionary<string, string> attributes = null)
{
attributes = attributes ?? new Dictionary<string, string>();
return new TagHelperOutput(tagName, attributes);
}
private static IHostingEnvironment MakeHostingEnvironment(IFileProvider webRootFileProvider = null)
{
var emptyDirectoryContents = new Mock<IDirectoryContents>();
emptyDirectoryContents.Setup(dc => dc.GetEnumerator())
.Returns(Enumerable.Empty<IFileInfo>().GetEnumerator());
if (webRootFileProvider == null)
{
var mockFileProvider = new Mock<IFileProvider>();
mockFileProvider.Setup(fp => fp.GetDirectoryContents(It.IsAny<string>()))
.Returns(emptyDirectoryContents.Object);
webRootFileProvider = mockFileProvider.Object;
}
var hostingEnvironment = new Mock<IHostingEnvironment>();
hostingEnvironment.Setup(h => h.WebRootFileProvider).Returns(webRootFileProvider);
return hostingEnvironment.Object;
}
}
}

View File

@ -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;

View File

@ -5,12 +5,114 @@
<head>
<meta charset="utf-8" />
<title>Link</title>
<link href="~/blank.css" rel="stylesheet" />
<link rel="stylesheet" href="~/link-test.min.css" data-extra="test"
asp-fallback-href="~/link-test.css"
<!-- Plain link tag -->
<link href="~/site.css" rel="stylesheet" />
<!-- Globbed link tag with existing file -->
<link asp-href-include="**/site.css" rel="stylesheet" />
<!-- Globbed link tag with existing file and exclude -->
<link asp-href-include="**/*.css" asp-href-exclude="**/site3.css" rel="stylesheet" />
<!-- Globbed link tag missing include -->
<link asp-href-exclude="**/site2.css" rel="stylesheet" />
<!-- Globbed link tag missing include but with static href -->
<link href="~/site.css" asp-href-exclude="**/site2.css" rel="stylesheet" />
<!-- Globbed link tag with missing file -->
<link asp-href-include="**/notThere.css" rel="stylesheet" />
<!-- Globbed link tag with file outside of webroot -->
<link asp-href-include="../**/appRoot.css" rel="stylesheet" />
<!-- Globbed link tag with file outside of webroot -->
<link asp-href-include="**/appRoot.css" rel="stylesheet" />
<!-- Globbed link tag with existing file and static href -->
<link href="~/site.css" asp-href-include="**/site2.css" rel="stylesheet" />
<!-- Globbed link tag with existing file and static href should dedupe -->
<link href="~/site.css" asp-href-include="**/site.css" rel="stylesheet" />
<!-- Fallback to static href -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href="~/site.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden" />
<!-- Fallback to static href with no primary href -->
<link rel="stylesheet" data-extra="test"
asp-fallback-href="~/site.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden" />
<!-- Fallback to globbed href -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href-include="**/site.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden" />
<!-- Fallback to static and globbed href -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href="~/site.css"
asp-fallback-href-include="**/site2.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden" />
<!-- Fallback to static and globbed href should dedupe -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href="~/site.css"
asp-fallback-href-include="**/site.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden" />
<!-- Fallback to static and globbed href with exclude -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href="~/site.css"
asp-fallback-href-include="**/*.css"
asp-fallback-href-exclude="**/site3.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden" />
<!-- Fallback to globbed href that doesn't exist -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href-include="**/notThere.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden" />
<!-- Fallback to globbed href outside webroot -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href-include="../**/appRoot.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden" />
<!-- Fallback with missing attribute -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href="~/site.css"
asp-fallback-test-property="visibility"
asp-fallback-test-value="hidden" />
<!-- Fallback with missing attribute -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href="~/site.css"
asp-fallback-test-class="hidden"
asp-fallback-test-value="hidden" />
<!-- Fallback with missing attribute -->
<link href="~/site.min.css" rel="stylesheet" data-extra="test"
asp-fallback-href="~/site.css"
asp-fallback-test-class="hidden"
asp-fallback-test-property="visibility" />
</head>
<body>

View File

@ -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!";
}

View File

@ -0,0 +1,6 @@
body::after {
display: block;
color: #0fa912;
margin-top: 2.4em;
content: "Stylesheet 'site.css' loaded successfully!";
}

View File

@ -0,0 +1,6 @@
body::after {
display: block;
color: #0fa912;
margin-top: 2.4em;
content: "Stylesheet 'site2.css' loaded successfully!";
}

View File

@ -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!";
}