diff --git a/samples/MvcSample.Web/HomeController.cs b/samples/MvcSample.Web/HomeController.cs index 00ca9e73a7..30341b570b 100644 --- a/samples/MvcSample.Web/HomeController.cs +++ b/samples/MvcSample.Web/HomeController.cs @@ -97,6 +97,11 @@ namespace MvcSample.Web Context.Response.WriteAsync("Hello World raw"); } + public ActionResult Language() + { + return View(); + } + [Produces("application/json", "application/xml", "application/custom", "text/json", Type = typeof(User))] public object ReturnUser() { diff --git a/samples/MvcSample.Web/LanguageViewLocationExpander.cs b/samples/MvcSample.Web/LanguageViewLocationExpander.cs new file mode 100644 index 0000000000..4ec43a9b63 --- /dev/null +++ b/samples/MvcSample.Web/LanguageViewLocationExpander.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.Razor; + +namespace MvcSample.Web +{ + /// + /// A that replaces adds the language as an extension prefix to view names. + /// + /// + /// For the default case with no areas, views are generated with the following patterns (assuming controller is + /// "Home", action is "Index" and language is "en") + /// Views/Home/en/Action + /// Views/Home/Action + /// Views/Shared/en/Action + /// Views/Shared/Action + /// + public class LanguageViewLocationExpander : IViewLocationExpander + { + private const string ValueKey = "language"; + private readonly Func _valueFactory; + + /// + /// Initializes a new instance of . + /// + /// A factory that provides tbe language to use for expansion. + public LanguageViewLocationExpander(Func valueFactory) + { + _valueFactory = valueFactory; + } + + /// + public void PopulateValues(ViewLocationExpanderContext context) + { + var value = _valueFactory(context.ActionContext); + if (!string.IsNullOrEmpty(value)) + { + context.Values[ValueKey] = value; + } + } + + /// + public virtual IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable viewLocations) + { + string value; + if (context.Values.TryGetValue(ValueKey, out value)) + { + return ExpandViewLocationsCore(viewLocations, value); + } + + return viewLocations; + } + + private IEnumerable ExpandViewLocationsCore(IEnumerable viewLocations, + string value) + { + foreach (var location in viewLocations) + { + yield return location.Replace("{0}", value + "/{0}"); + yield return location; + } + } + } +} \ No newline at end of file diff --git a/samples/MvcSample.Web/Startup.cs b/samples/MvcSample.Web/Startup.cs index 81131c7e7f..76d0b496d1 100644 --- a/samples/MvcSample.Web/Startup.cs +++ b/samples/MvcSample.Web/Startup.cs @@ -1,10 +1,10 @@ using System; using Microsoft.AspNet.Builder; using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.Razor; using Microsoft.AspNet.Routing; using Microsoft.Framework.ConfigurationModel; using Microsoft.Framework.DependencyInjection; -using Microsoft.Framework.OptionsModel; using MvcSample.Web.Filters; using MvcSample.Web.Services; @@ -46,6 +46,12 @@ namespace MvcSample.Web { options.Filters.Add(typeof(PassThroughAttribute), order: 17); }); + services.SetupOptions(options => + { + var expander = new LanguageViewLocationExpander( + context => context.HttpContext.Request.Query["language"]); + options.ViewLocationExpanders.Insert(0, expander); + }); // Create the autofac container ContainerBuilder builder = new ContainerBuilder(); diff --git a/samples/MvcSample.Web/Views/Home/Language.cshtml b/samples/MvcSample.Web/Views/Home/Language.cshtml new file mode 100644 index 0000000000..9f36c7f0f1 --- /dev/null +++ b/samples/MvcSample.Web/Views/Home/Language.cshtml @@ -0,0 +1,5 @@ +
    +
  • COLOR
  • +
  • HUMOR
  • +
  • ITEMIZE
  • +
diff --git a/samples/MvcSample.Web/Views/Home/en-gb/Language.cshtml b/samples/MvcSample.Web/Views/Home/en-gb/Language.cshtml new file mode 100644 index 0000000000..5d750371cb --- /dev/null +++ b/samples/MvcSample.Web/Views/Home/en-gb/Language.cshtml @@ -0,0 +1,5 @@ +
    +
  • COLOUR
  • +
  • HUMOUR
  • +
  • ITEMISE
  • +
diff --git a/samples/MvcSample.Web/Views/Home/fr/Language.cshtml b/samples/MvcSample.Web/Views/Home/fr/Language.cshtml new file mode 100644 index 0000000000..363c8b3f42 --- /dev/null +++ b/samples/MvcSample.Web/Views/Home/fr/Language.cshtml @@ -0,0 +1,5 @@ +
    +
  • COULEUR
  • +
  • HUMOUR
  • +
  • DÉTAILLER
  • +
diff --git a/samples/MvcSample.Web/Views/Shared/_Layout.cshtml b/samples/MvcSample.Web/Views/Shared/_Layout.cshtml index 2f5901fdb6..767b0b972e 100644 --- a/samples/MvcSample.Web/Views/Shared/_Layout.cshtml +++ b/samples/MvcSample.Web/Views/Shared/_Layout.cshtml @@ -5,6 +5,12 @@ @ViewBag.Title - My ASP.NET Application + @RenderSection("header", required: false) diff --git a/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs b/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs index e47e149eca..45f0ac44a1 100644 --- a/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs +++ b/src/Microsoft.AspNet.Mvc.Core/MvcOptions.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using Microsoft.AspNet.Mvc.Core; using Microsoft.AspNet.Mvc.OptionDescriptors; using Microsoft.AspNet.Mvc.ReflectedModelBuilder; @@ -16,7 +15,6 @@ namespace Microsoft.AspNet.Mvc public class MvcOptions { private AntiForgeryOptions _antiForgeryOptions = new AntiForgeryOptions(); - private RazorViewEngineOptions _viewEngineOptions = new RazorViewEngineOptions(); private int _maxModelStateErrors = 200; public MvcOptions() @@ -71,29 +69,6 @@ namespace Microsoft.AspNet.Mvc /// public List InputFormatters { get; private set; } - /// - /// Provides programmatic configuration for the default . - /// - public RazorViewEngineOptions ViewEngineOptions - { - get - { - return _viewEngineOptions; - } - - set - { - if (value == null) - { - throw new ArgumentNullException("value", - Resources.FormatPropertyOfTypeCannotBeNull("ViewEngineOptions", - typeof(MvcOptions))); - } - - _viewEngineOptions = value; - } - } - /// /// Gets or sets the maximum number of validation errors that are allowed by this application before further /// errors are ignored. @@ -123,8 +98,8 @@ namespace Microsoft.AspNet.Mvc /// Gets a list of the s used by /// . /// - public List ModelValidatorProviders { get; } = - new List(); + public List ModelValidatorProviders { get; } + = new List(); /// /// Gets a list of descriptors that represent used diff --git a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs index 3664b0dcd7..be86cd7f05 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs @@ -1354,6 +1354,54 @@ namespace Microsoft.AspNet.Mvc.Core return string.Format(CultureInfo.CurrentCulture, GetString("AttributeRoute_AggregateErrorMessage_ErrorNumber"), p0, p1, p2); } + /// + /// Could not find a replacement for view expansion token '{0}'. + /// + internal static string TemplatedViewLocationExpander_NoReplacementToken + { + get { return GetString("TemplatedViewLocationExpander_NoReplacementToken"); } + } + + /// + /// Could not find a replacement for view expansion token '{0}'. + /// + internal static string FormatTemplatedViewLocationExpander_NoReplacementToken(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("TemplatedViewLocationExpander_NoReplacementToken"), p0); + } + + /// + /// {0} must be executed before {1} can be invoked. + /// + internal static string TemplatedExpander_PopulateValuesMustBeInvokedFirst + { + get { return GetString("TemplatedExpander_PopulateValuesMustBeInvokedFirst"); } + } + + /// + /// {0} must be executed before {1} can be invoked. + /// + internal static string FormatTemplatedExpander_PopulateValuesMustBeInvokedFirst(object p0, object p1) + { + return string.Format(CultureInfo.CurrentCulture, GetString("TemplatedExpander_PopulateValuesMustBeInvokedFirst"), p0, p1); + } + + /// + /// The result of value factory cannot be null. + /// + internal static string TemplatedExpander_ValueFactoryCannotReturnNull + { + get { return GetString("TemplatedExpander_ValueFactoryCannotReturnNull"); } + } + + /// + /// The result of value factory cannot be null. + /// + internal static string FormatTemplatedExpander_ValueFactoryCannotReturnNull() + { + return GetString("TemplatedExpander_ValueFactoryCannotReturnNull"); + } + /// /// A method '{0}' that defines attribute routed actions must not have attributes that implement '{1}' and do not implement '{2}':{3}{4} /// diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/CompositeViewEngine.cs b/src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/CompositeViewEngine.cs similarity index 100% rename from src/Microsoft.AspNet.Mvc.Core/Rendering/CompositeViewEngine.cs rename to src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/CompositeViewEngine.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/ICompositeViewEngine.cs b/src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/ICompositeViewEngine.cs similarity index 100% rename from src/Microsoft.AspNet.Mvc.Core/Rendering/ICompositeViewEngine.cs rename to src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/ICompositeViewEngine.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/IView.cs b/src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/IView.cs similarity index 100% rename from src/Microsoft.AspNet.Mvc.Core/Rendering/IView.cs rename to src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/IView.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/IViewEngine.cs b/src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/IViewEngine.cs similarity index 100% rename from src/Microsoft.AspNet.Mvc.Core/Rendering/IViewEngine.cs rename to src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/IViewEngine.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/IViewEngineProvider.cs b/src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/IViewEngineProvider.cs similarity index 100% rename from src/Microsoft.AspNet.Mvc.Core/Rendering/IViewEngineProvider.cs rename to src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/IViewEngineProvider.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngineResult.cs b/src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/ViewEngineResult.cs similarity index 100% rename from src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngineResult.cs rename to src/Microsoft.AspNet.Mvc.Core/Rendering/ViewEngine/ViewEngineResult.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/Resources.resx b/src/Microsoft.AspNet.Mvc.Core/Resources.resx index ce9383a19a..dc3b141a1a 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Core/Resources.resx @@ -375,6 +375,15 @@ Error {0}:{1}{2} {0} is the error number, {1} is Environment.NewLine {2} is the error message + + Could not find a replacement for view expansion token '{0}'. + + + {0} must be executed before {1} can be invoked. + + + The result of value factory cannot be null. + A method '{0}' that defines attribute routed actions must not have attributes that implement '{1}' and do not implement '{2}':{3}{4} {0} is the MethodInfo.FullName, {1} is typeof(IActionHttpMethodProvider).FullName, {2} is typeof(IRouteTemplateProvider).FullName, {3} is Environment.NewLine, {4} is the list of actions and their respective invalid IActionHttpMethodProvider attributes formatted using AttributeRoute_InvalidHttpMethodConstraints_Item diff --git a/src/Microsoft.AspNet.Mvc.Core/project.json b/src/Microsoft.AspNet.Mvc.Core/project.json index e6aaf6769c..e09ad3b4b5 100644 --- a/src/Microsoft.AspNet.Mvc.Core/project.json +++ b/src/Microsoft.AspNet.Mvc.Core/project.json @@ -11,7 +11,7 @@ "Microsoft.AspNet.Mvc.ModelBinding": "", "Microsoft.AspNet.Routing": "1.0.0-*", "Microsoft.AspNet.Security": "1.0.0-*", - "Microsoft.AspNet.Security.DataProtection" : "1.0.0-*", + "Microsoft.AspNet.Security.DataProtection": "1.0.0-*", "Microsoft.Framework.DependencyInjection": "1.0.0-*", "Microsoft.Framework.Runtime.Interfaces": "1.0.0-*", "Microsoft.Framework.Logging": "1.0.0-*", diff --git a/src/Microsoft.AspNet.Mvc.Core/ExpiringFileInfoCache.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/ExpiringFileInfoCache.cs similarity index 91% rename from src/Microsoft.AspNet.Mvc.Core/ExpiringFileInfoCache.cs rename to src/Microsoft.AspNet.Mvc.Razor/Compilation/ExpiringFileInfoCache.cs index e3f4e9f15e..0c37c64e9b 100644 --- a/src/Microsoft.AspNet.Mvc.Core/ExpiringFileInfoCache.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/ExpiringFileInfoCache.cs @@ -7,7 +7,7 @@ using Microsoft.AspNet.FileSystems; using Microsoft.Framework.OptionsModel; using Microsoft.Framework.Runtime; -namespace Microsoft.AspNet.Mvc.Core +namespace Microsoft.AspNet.Mvc.Razor { /// /// A default implementation for the interface. @@ -37,11 +37,11 @@ namespace Microsoft.AspNet.Mvc.Core } public ExpiringFileInfoCache(IApplicationEnvironment env, - IOptionsAccessor optionsAccessor) + IOptionsAccessor optionsAccessor) { // TODO: Inject the IFileSystem but only when we get it from the host _fileSystem = new PhysicalFileSystem(env.ApplicationBasePath); - _offset = optionsAccessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk; + _offset = optionsAccessor.Options.ExpirationBeforeCheckingFilesOnDisk; } /// diff --git a/src/Microsoft.AspNet.Mvc.Core/IExpiringFileInfoCache.cs b/src/Microsoft.AspNet.Mvc.Razor/Compilation/IExpiringFileInfoCache.cs similarity index 94% rename from src/Microsoft.AspNet.Mvc.Core/IExpiringFileInfoCache.cs rename to src/Microsoft.AspNet.Mvc.Razor/Compilation/IExpiringFileInfoCache.cs index c566219e27..a6408a06b9 100644 --- a/src/Microsoft.AspNet.Mvc.Core/IExpiringFileInfoCache.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Compilation/IExpiringFileInfoCache.cs @@ -3,7 +3,7 @@ using Microsoft.AspNet.FileSystems; -namespace Microsoft.AspNet.Mvc.Core +namespace Microsoft.AspNet.Mvc.Razor { /// /// Provides cached access to file infos. diff --git a/src/Microsoft.AspNet.Mvc.Razor/DefaultViewLocationCache.cs b/src/Microsoft.AspNet.Mvc.Razor/DefaultViewLocationCache.cs new file mode 100644 index 0000000000..28e30608a3 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/DefaultViewLocationCache.cs @@ -0,0 +1,81 @@ +// 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.Linq; +using System.Text; + +namespace Microsoft.AspNet.Mvc.Razor +{ + /// + /// Default implementation of . + /// + public class DefaultViewLocationCache : IViewLocationCache + { + private const char CacheKeySeparator = ':'; + + // A mapping of keys generated from ViewLocationExpanderContext to view locations. + private readonly ConcurrentDictionary _cache; + + /// + /// Initializes a new instance of . + /// + public DefaultViewLocationCache() + { + _cache = new ConcurrentDictionary(StringComparer.Ordinal); + } + + /// + public string Get([NotNull] ViewLocationExpanderContext context) + { + var cacheKey = GenerateKey(context); + string result; + _cache.TryGetValue(cacheKey, out result); + return result; + } + + /// + public void Set([NotNull] ViewLocationExpanderContext context, + [NotNull] string value) + { + var cacheKey = GenerateKey(context); + _cache.TryAdd(cacheKey, value); + } + + internal static string GenerateKey(ViewLocationExpanderContext context) + { + var keyBuilder = new StringBuilder(); + var routeValues = context.ActionContext.RouteData.Values; + var controller = routeValues.GetValueOrDefault(RazorViewEngine.ControllerKey); + + // format is "{viewName}:{controllerName}:{areaName}:" + keyBuilder.Append(context.ViewName) + .Append(CacheKeySeparator) + .Append(controller); + + var area = routeValues.GetValueOrDefault(RazorViewEngine.AreaKey); + if (!string.IsNullOrEmpty(area)) + { + keyBuilder.Append(CacheKeySeparator) + .Append(area); + } + + if (context.Values != null) + { + var valuesDictionary = context.Values; + foreach (var item in valuesDictionary.OrderBy(k => k.Key, StringComparer.Ordinal)) + { + keyBuilder.Append(CacheKeySeparator) + .Append(item.Key) + .Append(CacheKeySeparator) + .Append(item.Value); + } + } + + var cacheKey = keyBuilder.ToString(); + return cacheKey; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/IViewLocationCache.cs b/src/Microsoft.AspNet.Mvc.Razor/IViewLocationCache.cs new file mode 100644 index 0000000000..1689120cba --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/IViewLocationCache.cs @@ -0,0 +1,27 @@ +// 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. + +namespace Microsoft.AspNet.Mvc.Razor +{ + /// + /// Specifies the contracts for caching view locations generated by . + /// + public interface IViewLocationCache + { + /// + /// Gets a cached view location based on the specified . + /// + /// The for the current view location + /// expansion. + /// The cached location, if available, null otherwise. + string Get(ViewLocationExpanderContext context); + + /// + /// Adds a cache entry for values specified by . + /// + /// The for the current view location + /// expansion. + /// The view location that is to be cached. + void Set(ViewLocationExpanderContext context, string value); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/IViewLocationExpander.cs b/src/Microsoft.AspNet.Mvc.Razor/IViewLocationExpander.cs new file mode 100644 index 0000000000..037a3935d5 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/IViewLocationExpander.cs @@ -0,0 +1,45 @@ +// 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.Razor +{ + /// + /// Specifies the contracts for a view location expander that is used by instances to + /// determine search paths for a view. + /// + /// + /// Individual s are invoked in two steps: + /// (1) is invoked and each expander + /// adds values that it would later consume as part of + /// . + /// The populated values are used to determine a cache key - if all values are identical to the last time + /// was invoked, the cached result + /// is used as the view location. + /// (2) If no result was found in the cache or if a view was not found at the cached location, + /// is invoked to determine + /// all potential paths for a view. + /// + public interface IViewLocationExpander + { + /// + /// Invoked by a to determine the values that would be consumed by this instance of + /// . The calculated values are used to determine if the view location has + /// changed since the last time it was located. + /// + /// The for the current view location + /// expansion operation. + void PopulateValues(ViewLocationExpanderContext context); + + /// + /// Invoked by a to determine potential locations for a view. + /// + /// The for the current view location + /// expansion operation. + /// The sequence of view locations to expand. + /// A list of expanded view locations. + IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable viewLocations); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/DefaultViewLocationExpanderProvider.cs b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/DefaultViewLocationExpanderProvider.cs new file mode 100644 index 0000000000..98b1c5c2cb --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/DefaultViewLocationExpanderProvider.cs @@ -0,0 +1,37 @@ +// 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 Microsoft.AspNet.Mvc.OptionDescriptors; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.OptionsModel; + +namespace Microsoft.AspNet.Mvc.Razor.OptionDescriptors +{ + /// + public class DefaultViewLocationExpanderProvider : + OptionDescriptorBasedProvider, IViewLocationExpanderProvider + { + /// + /// Initializes a new instance of the class. + /// + /// An accessor to the configured for this application. + /// An instance used to instantiate types. + /// A instance that retrieves services from the + /// service collection. + public DefaultViewLocationExpanderProvider( + IOptionsAccessor optionsAccessor, + ITypeActivator typeActivator, + IServiceProvider serviceProvider) + : base(optionsAccessor.Options.ViewLocationExpanders, typeActivator, serviceProvider) + { + } + + /// + public IReadOnlyList ViewLocationExpanders + { + get { return Options; } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/IViewLocationExpanderProvider.cs b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/IViewLocationExpanderProvider.cs new file mode 100644 index 0000000000..e788225028 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/IViewLocationExpanderProvider.cs @@ -0,0 +1,18 @@ +// 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.Razor.OptionDescriptors +{ + /// + /// Provides an activated collection of instances. + /// + public interface IViewLocationExpanderProvider + { + /// + /// Gets a collection of activated instances. + /// + IReadOnlyList ViewLocationExpanders { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Rendering/RazorViewEngineOptions.cs b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/RazorViewEngineOptions.cs similarity index 75% rename from src/Microsoft.AspNet.Mvc.Core/Rendering/RazorViewEngineOptions.cs rename to src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/RazorViewEngineOptions.cs index b4dc8ae008..53d534ecd7 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Rendering/RazorViewEngineOptions.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/RazorViewEngineOptions.cs @@ -2,8 +2,10 @@ // 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 Microsoft.AspNet.Mvc.Razor.OptionDescriptors; -namespace Microsoft.AspNet.Mvc.Core +namespace Microsoft.AspNet.Mvc.Razor { /// /// Provides programmatic configuration for the default . @@ -38,5 +40,12 @@ namespace Microsoft.AspNet.Mvc.Core } } } + + /// + /// Get a of descriptors for s used by this + /// application. + /// + public IList ViewLocationExpanders { get; } + = new List(); } } diff --git a/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/ViewLocationExpanderDescriptor.cs b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/ViewLocationExpanderDescriptor.cs new file mode 100644 index 0000000000..eb20fdbe40 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/ViewLocationExpanderDescriptor.cs @@ -0,0 +1,34 @@ +// 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.Mvc.OptionDescriptors; + +namespace Microsoft.AspNet.Mvc.Razor.OptionDescriptors +{ + /// + /// Encapsulates information that describes an . + /// + public class ViewLocationExpanderDescriptor : OptionDescriptor + { + /// + /// Creates a new instance of . + /// + /// A type that the descriptor represents. + /// + public ViewLocationExpanderDescriptor([NotNull] Type type) + : base(type) + { + } + + /// + /// Creates a new instance of . + /// + /// An instance of + /// that the descriptor represents. + public ViewLocationExpanderDescriptor([NotNull] IViewLocationExpander viewLocationExpander) + : base(viewLocationExpander) + { + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/ViewLocationExpanderDescriptorExtensions.cs b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/ViewLocationExpanderDescriptorExtensions.cs new file mode 100644 index 0000000000..a8c7a7f370 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/OptionDescriptors/ViewLocationExpanderDescriptorExtensions.cs @@ -0,0 +1,93 @@ +// 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 Microsoft.AspNet.Mvc.Razor.OptionDescriptors; + +namespace Microsoft.AspNet.Mvc.Razor +{ + /// + /// Extension methods for adding view location expanders to a collection. + /// + public static class ViewLocationExpanderDescriptorExtensions + { + /// + /// Adds a type representing a to . + /// + /// A list of . + /// Type representing an + /// A representing the added instance. + public static ViewLocationExpanderDescriptor Add( + [NotNull] this IList descriptors, + [NotNull] Type viewLocationExpanderType) + { + var descriptor = new ViewLocationExpanderDescriptor(viewLocationExpanderType); + descriptors.Add(descriptor); + return descriptor; + } + + /// + /// Inserts a type representing a in to at + /// the specified . + /// + /// A list of . + /// The zero-based index at which + /// should be inserted. + /// Type representing an + /// A representing the inserted instance. + public static ViewLocationExpanderDescriptor Insert( + [NotNull] this IList descriptors, + int index, + [NotNull] Type viewLocationExpanderType) + { + if (index < 0 || index > descriptors.Count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + var descriptor = new ViewLocationExpanderDescriptor(viewLocationExpanderType); + descriptors.Insert(index, descriptor); + return descriptor; + } + + /// + /// Adds an to . + /// + /// A list of . + /// An instance. + /// A representing the added instance. + public static ViewLocationExpanderDescriptor Add( + [NotNull] this IList descriptors, + [NotNull] IViewLocationExpander viewLocationExpander) + { + var descriptor = new ViewLocationExpanderDescriptor(viewLocationExpander); + descriptors.Add(descriptor); + return descriptor; + } + + /// + /// Insert an in to at the specified + /// . + /// + /// A list of . + /// The zero-based index at which + /// should be inserted. + /// An instance. + /// A representing the added instance. + public static ViewLocationExpanderDescriptor Insert( + [NotNull] this IList descriptors, + int index, + [NotNull] IViewLocationExpander viewLocationExpander) + { + if (index < 0 || index > descriptors.Count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + var descriptor = new ViewLocationExpanderDescriptor(viewLocationExpander); + descriptors.Insert(index, descriptor); + return descriptor; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs index 0f8f2058a9..1d07b517ca 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/Properties/Resources.Designer.cs @@ -266,6 +266,22 @@ namespace Microsoft.AspNet.Mvc.Razor return string.Format(CultureInfo.CurrentCulture, GetString("ViewContextMustBeSet"), p0, p1); } + /// + /// '{0}' must be a {1} that is generated as result of the call to '{2}'. + /// + internal static string ViewLocationCache_KeyMustBeString + { + get { return GetString("ViewLocationCache_KeyMustBeString"); } + } + + /// + /// '{0}' must be a {1} that is generated as result of the call to '{2}'. + /// + internal static string FormatViewLocationCache_KeyMustBeString(object p0, object p1, object p2) + { + return string.Format(CultureInfo.CurrentCulture, GetString("ViewLocationCache_KeyMustBeString"), p0, p1, p2); + } + /// /// The '{0}' method must be called before '{1}' can be invoked. /// diff --git a/src/Microsoft.AspNet.Mvc.Razor/RazorViewEngine.cs b/src/Microsoft.AspNet.Mvc.Razor/RazorViewEngine.cs index 6b476b4d50..bdc123112a 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/RazorViewEngine.cs +++ b/src/Microsoft.AspNet.Mvc.Razor/RazorViewEngine.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; +using Microsoft.AspNet.Mvc.Razor.OptionDescriptors; using Microsoft.AspNet.Mvc.Rendering; using Microsoft.Framework.DependencyInjection; @@ -16,14 +16,16 @@ namespace Microsoft.AspNet.Mvc.Razor public class RazorViewEngine : IViewEngine { private const string ViewExtension = ".cshtml"; + internal const string ControllerKey = "controller"; + internal const string AreaKey = "area"; - private static readonly string[] _viewLocationFormats = + private static readonly IEnumerable _viewLocationFormats = new[] { "/Views/{1}/{0}" + ViewExtension, "/Views/Shared/{0}" + ViewExtension, }; - private static readonly string[] _areaViewLocationFormats = + private static readonly IEnumerable _areaViewLocationFormats = new[] { "/Areas/{2}/Views/{1}/{0}" + ViewExtension, "/Areas/{2}/Views/Shared/{0}" + ViewExtension, @@ -31,27 +33,44 @@ namespace Microsoft.AspNet.Mvc.Razor }; private readonly IRazorPageFactory _pageFactory; + private readonly IReadOnlyList _viewLocationExpanders; + private readonly IViewLocationCache _viewLocationCache; /// /// Initializes a new instance of the class. /// /// The page factory used for creating instances. - public RazorViewEngine(IRazorPageFactory pageFactory) + public RazorViewEngine(IRazorPageFactory pageFactory, + IViewLocationExpanderProvider viewLocationExpanderProvider, + IViewLocationCache viewLocationCache) { _pageFactory = pageFactory; + _viewLocationExpanders = viewLocationExpanderProvider.ViewLocationExpanders; + _viewLocationCache = viewLocationCache; } - public IEnumerable ViewLocationFormats + /// + /// Gets the locations where this instance of will search for views. + /// + public virtual IEnumerable ViewLocationFormats { get { return _viewLocationFormats; } } + /// + /// Gets the locations where this instance of will search for views within an + /// area. + /// + public virtual IEnumerable AreaViewLocationFormats + { + get { return _areaViewLocationFormats; } + } + /// public ViewEngineResult FindView([NotNull] ActionContext context, [NotNull] string viewName) { - var viewEngineResult = CreateViewEngineResult(context, viewName, partial: false); - return viewEngineResult; + return CreateViewEngineResult(context, viewName, partial: false); } /// @@ -81,22 +100,77 @@ namespace Microsoft.AspNet.Mvc.Razor } else { - var routeValues = context.RouteData.Values; - var controllerName = routeValues.GetValueOrDefault("controller"); - var areaName = routeValues.GetValueOrDefault("area"); - var potentialPaths = GetViewSearchPaths(viewName, controllerName, areaName); + return LocateViewFromViewLocations(context, viewName, partial); + } + } - foreach (var path in potentialPaths) + private ViewEngineResult LocateViewFromViewLocations(ActionContext context, + string viewName, + bool partial) + { + // Initialize the dictionary for the typical case of having controller and action tokens. + var routeValues = context.RouteData.Values; + var areaName = routeValues.GetValueOrDefault(AreaKey); + + // Only use the area view location formats if we have an area token. + var viewLocations = !string.IsNullOrEmpty(areaName) ? AreaViewLocationFormats : + ViewLocationFormats; + + var expanderContext = new ViewLocationExpanderContext(context, viewName); + if (_viewLocationExpanders.Count > 0) + { + expanderContext.Values = new Dictionary(StringComparer.Ordinal); + + // 1. Populate values from viewLocationExpanders. + foreach (var expander in _viewLocationExpanders) { - var page = _pageFactory.CreateInstance(path); - if (page != null) - { - return CreateFoundResult(context, page, path, partial); - } + expander.PopulateValues(expanderContext); + } + } + + // 2. With the values that we've accumumlated so far, check if we have a cached result. + var viewLocation = _viewLocationCache.Get(expanderContext); + if (!string.IsNullOrEmpty(viewLocation)) + { + var page = _pageFactory.CreateInstance(viewLocation); + + if (page != null) + { + // 2a. We found a IRazorPage at the cached location. + return CreateFoundResult(context, page, viewName, partial); + } + } + + // 2b. We did not find a cached location or did not find a IRazorPage at the cached location. + // The cached value has expired and we need to look up the page. + foreach (var expander in _viewLocationExpanders) + { + viewLocations = expander.ExpandViewLocations(expanderContext, viewLocations); + } + + // 3. Use the expanded locations to look up a page. + var controllerName = routeValues.GetValueOrDefault(ControllerKey); + var searchedLocations = new List(); + foreach (var path in viewLocations) + { + var transformedPath = string.Format(CultureInfo.InvariantCulture, + path, + viewName, + controllerName, + areaName); + var page = _pageFactory.CreateInstance(transformedPath); + if (page != null) + { + // 3a. We found a page. Cache the set of values that produced it and return a found result. + _viewLocationCache.Set(expanderContext, transformedPath); + return CreateFoundResult(context, page, transformedPath, partial); } - return ViewEngineResult.NotFound(viewName, potentialPaths); + searchedLocations.Add(transformedPath); } + + // 3b. We did not find a page for any of the paths. + return ViewEngineResult.NotFound(viewName, searchedLocations); } private ViewEngineResult CreateFoundResult(ActionContext actionContext, @@ -117,26 +191,5 @@ namespace Microsoft.AspNet.Mvc.Razor { return name[0] == '~' || name[0] == '/'; } - - private IEnumerable GetViewSearchPaths(string viewName, string controllerName, string areaName) - { - IEnumerable unformattedPaths; - - if (string.IsNullOrEmpty(areaName)) - { - // If no areas then no need to search area locations. - unformattedPaths = _viewLocationFormats; - } - else - { - // If there's an area provided only search area view locations - unformattedPaths = _areaViewLocationFormats; - } - - var formattedPaths = unformattedPaths.Select(path => - string.Format(CultureInfo.InvariantCulture, path, viewName, controllerName, areaName)); - - return formattedPaths; - } } } diff --git a/src/Microsoft.AspNet.Mvc.Razor/Resources.resx b/src/Microsoft.AspNet.Mvc.Razor/Resources.resx index 611ea48847..9d0f92e270 100644 --- a/src/Microsoft.AspNet.Mvc.Razor/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Razor/Resources.resx @@ -165,6 +165,9 @@ '{0} must be set to access '{1}'. + + '{0}' must be a {1} that is generated as result of the call to '{2}'. + The '{0}' method must be called before '{1}' can be invoked. diff --git a/src/Microsoft.AspNet.Mvc.Razor/ViewLocationExpanderContext.cs b/src/Microsoft.AspNet.Mvc.Razor/ViewLocationExpanderContext.cs new file mode 100644 index 0000000000..98d0a4fb82 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Razor/ViewLocationExpanderContext.cs @@ -0,0 +1,36 @@ +// 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.Razor +{ + /// + /// A context for containing information for . + /// + public class ViewLocationExpanderContext + { + public ViewLocationExpanderContext([NotNull] ActionContext actionContext, + [NotNull] string viewName) + { + ActionContext = actionContext; + ViewName = viewName; + } + + /// + /// Gets the for the current executing action. + /// + public ActionContext ActionContext { get; private set; } + + /// + /// Gets the view name + /// + public string ViewName { get; private set; } + + /// + /// Gets or sets the that is populated with values as part of + /// . + /// + public IDictionary Values { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs b/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs index 46484c9f8a..955f03606f 100644 --- a/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs +++ b/src/Microsoft.AspNet.Mvc/MvcOptionsSetup.cs @@ -3,6 +3,7 @@ using Microsoft.AspNet.Mvc.ModelBinding; using Microsoft.AspNet.Mvc.Razor; +using Microsoft.AspNet.Mvc.Rendering; using Microsoft.Framework.OptionsModel; namespace Microsoft.AspNet.Mvc diff --git a/src/Microsoft.AspNet.Mvc/MvcServices.cs b/src/Microsoft.AspNet.Mvc/MvcServices.cs index f175ce64a3..a236e8d32c 100644 --- a/src/Microsoft.AspNet.Mvc/MvcServices.cs +++ b/src/Microsoft.AspNet.Mvc/MvcServices.cs @@ -10,6 +10,7 @@ using Microsoft.AspNet.Mvc.ModelBinding; using Microsoft.AspNet.Mvc.OptionDescriptors; using Microsoft.AspNet.Mvc.Razor; using Microsoft.AspNet.Mvc.Razor.Compilation; +using Microsoft.AspNet.Mvc.Razor.OptionDescriptors; using Microsoft.AspNet.Mvc.Rendering; using Microsoft.AspNet.Mvc.Routing; using Microsoft.AspNet.Security; @@ -53,6 +54,11 @@ namespace Microsoft.AspNet.Mvc yield return describe.Singleton(); yield return describe.Transient(); + // Transient since the IViewLocationExpanders returned by the instance is cached by view engines. + yield return describe.Transient(); + // Caches view locations that are valid for the lifetime of the application. + yield return describe.Singleton(); + yield return describe.Singleton(); // Virtual path view factory needs to stay scoped so views can get get scoped services. yield return describe.Scoped(); diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/OptionDescriptors/ViewEngineDscriptorExtensionsTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/OptionDescriptors/ViewEngineDescriptorExtensionsTest.cs similarity index 100% rename from test/Microsoft.AspNet.Mvc.Core.Test/OptionDescriptors/ViewEngineDscriptorExtensionsTest.cs rename to test/Microsoft.AspNet.Mvc.Core.Test/OptionDescriptors/ViewEngineDescriptorExtensionsTest.cs diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/Rendering/CompositeViewEngineTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/Rendering/ViewEngine/CompositeViewEngineTest.cs similarity index 100% rename from test/Microsoft.AspNet.Mvc.Core.Test/Rendering/CompositeViewEngineTest.cs rename to test/Microsoft.AspNet.Mvc.Core.Test/Rendering/ViewEngine/CompositeViewEngineTest.cs diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/ViewEngineTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/ViewEngineTests.cs index cf5b6b15c5..b1839be16a 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/ViewEngineTests.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/ViewEngineTests.cs @@ -101,5 +101,42 @@ component-content"; // Assert Assert.Equal(expected, body.Trim()); } + + public static IEnumerable RazorViewEngine_UsesAllExpandedPathsToLookForViewsData + { + get + { + var expected1 = string.Join(Environment.NewLine, + "expander-index", + "gb-partial"); + yield return new[] { "gb", expected1 }; + + var expected2 = string.Join(Environment.NewLine, + "fr-index", + "fr-partial"); + yield return new[] { "fr", expected2 }; + + var expected3 = string.Join(Environment.NewLine, + "expander-index", + "expander-partial"); + yield return new[] { "na", expected3 }; + } + } + + [Theory] + [MemberData(nameof(RazorViewEngine_UsesAllExpandedPathsToLookForViewsData))] + public async Task RazorViewEngine_UsesViewExpandersForViewsAndPartials(string value, string expected) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var body = await client.GetStringAsync("http://localhost/TemplateExpander?language-expander-value=" + + value); + + // Assert + Assert.Equal(expected, body.Trim()); + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ExpiringFileInfoCacheTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/ExpiringFileInfoCacheTest.cs similarity index 92% rename from test/Microsoft.AspNet.Mvc.Core.Test/ExpiringFileInfoCacheTest.cs rename to test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/ExpiringFileInfoCacheTest.cs index 0aaaf1ce7d..3efe7db857 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ExpiringFileInfoCacheTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/Compilation/ExpiringFileInfoCacheTest.cs @@ -10,7 +10,7 @@ using Microsoft.Framework.Runtime; using Moq; using Xunit; -namespace Microsoft.AspNet.Mvc.Core.Test +namespace Microsoft.AspNet.Mvc.Razor { public class ExpiringFileInfoCacheTest { @@ -27,28 +27,28 @@ namespace Microsoft.AspNet.Mvc.Core.Test } } - public MvcOptions Options + public RazorViewEngineOptions Options { get { - return new MvcOptions(); + return new RazorViewEngineOptions(); } } - public IOptionsAccessor OptionsAccessor + public IOptionsAccessor OptionsAccessor { get { var options = Options; - var mock = new Mock>(MockBehavior.Strict); + var mock = new Mock>(MockBehavior.Strict); mock.Setup(oa => oa.Options).Returns(options); return mock.Object; } } - public ControllableExpiringFileInfoCache GetCache(IOptionsAccessor optionsAccessor) + public ControllableExpiringFileInfoCache GetCache(IOptionsAccessor optionsAccessor) { return new ControllableExpiringFileInfoCache(ApplicationEnvironment, optionsAccessor); } @@ -69,16 +69,16 @@ namespace Microsoft.AspNet.Mvc.Core.Test cache.Sleep(offsetMilliseconds); } - public void Sleep(IOptionsAccessor accessor, ControllableExpiringFileInfoCache cache, int offsetMilliSeconds) + public void Sleep(IOptionsAccessor accessor, ControllableExpiringFileInfoCache cache, int offsetMilliSeconds) { - var baseMilliSeconds = (int)accessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk.TotalMilliseconds; + var baseMilliSeconds = (int)accessor.Options.ExpirationBeforeCheckingFilesOnDisk.TotalMilliseconds; cache.Sleep(baseMilliSeconds + offsetMilliSeconds); } - public void SetExpiration(IOptionsAccessor accessor, TimeSpan expiration) + public void SetExpiration(IOptionsAccessor accessor, TimeSpan expiration) { - accessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk = expiration; + accessor.Options.ExpirationBeforeCheckingFilesOnDisk = expiration; } [Fact] @@ -87,7 +87,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test var optionsAccessor = OptionsAccessor; // Assert - Assert.Equal(2000, optionsAccessor.Options.ViewEngineOptions.ExpirationBeforeCheckingFilesOnDisk.TotalMilliseconds); + Assert.Equal(2000, optionsAccessor.Options.ExpirationBeforeCheckingFilesOnDisk.TotalMilliseconds); } [Fact] @@ -323,7 +323,7 @@ namespace Microsoft.AspNet.Mvc.Core.Test public class ControllableExpiringFileInfoCache : ExpiringFileInfoCache { public ControllableExpiringFileInfoCache(IApplicationEnvironment env, - IOptionsAccessor optionsAccessor) + IOptionsAccessor optionsAccessor) : base(env, optionsAccessor) { } diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/DefaultViewLocationCacheTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/DefaultViewLocationCacheTest.cs new file mode 100644 index 0000000000..ed9fbc6919 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/DefaultViewLocationCacheTest.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 Microsoft.AspNet.PipelineCore; +using Microsoft.AspNet.Routing; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Razor +{ + public class DefaultViewLocationCacheTest + { + public static IEnumerable CacheEntryData + { + get + { + yield return new[] { new ViewLocationExpanderContext(GetActionContext(), "test") }; + + var areaActionContext = GetActionContext("controller2", "myarea"); + yield return new[] { new ViewLocationExpanderContext(areaActionContext, "test2") }; + + var actionContext = GetActionContext("controller3", "area3"); + var values = new Dictionary(StringComparer.Ordinal) + { + { "culture", "fr" }, + { "theme", "sleek" } + }; + var expanderContext = new ViewLocationExpanderContext(actionContext, "test3") + { + Values = values + }; + + yield return new [] { expanderContext }; + } + } + + [Theory] + [MemberData(nameof(CacheEntryData))] + public void Get_GeneratesCacheKeyIfItemDoesNotExist(ViewLocationExpanderContext context) + { + // Arrange + var cache = new DefaultViewLocationCache(); + + // Act + var result = cache.Get(context); + + // Assert + Assert.Null(result); + } + + [Theory] + [MemberData(nameof(CacheEntryData))] + public void InvokingGetAfterSet_ReturnsCachedItem(ViewLocationExpanderContext context) + { + // Arrange + var cache = new DefaultViewLocationCache(); + var value = Guid.NewGuid().ToString(); + + // Act + cache.Set(context, value); + var result = cache.Get(context); + + // Assert + Assert.Equal(value, result); + } + + public static IEnumerable CacheKeyData + { + get + { + yield return new object[] + { + new ViewLocationExpanderContext(GetActionContext(), "test"), + "test:mycontroller" + }; + + var areaActionContext = GetActionContext("controller2", "myarea"); + yield return new object[] + { + new ViewLocationExpanderContext(areaActionContext, "test2"), + "test2:controller2:myarea" + }; + + var actionContext = GetActionContext("controller3", "area3"); + var values = new Dictionary(StringComparer.Ordinal) + { + { "culture", "fr" }, + { "theme", "sleek" } + }; + var expanderContext = new ViewLocationExpanderContext(actionContext, "test3") + { + Values = values + }; + + yield return new object[] + { + expanderContext, + "test3:controller3:area3:culture:fr:theme:sleek" + }; + } + } + + [Theory] + [MemberData(nameof(CacheKeyData))] + public void CacheKeyIsComputedBasedOnValuesInExpander(ViewLocationExpanderContext context, string expected) + { + // Act + var result = DefaultViewLocationCache.GenerateKey(context); + + // Assert + Assert.Equal(expected, result); + } + + public static ActionContext GetActionContext(string controller = "mycontroller", + string area = null) + { + var routeData = new RouteData + { + Values = new Dictionary(StringComparer.OrdinalIgnoreCase) + }; + routeData.Values["controller"] = controller; + if (area != null) + { + routeData.Values["area"] = area; + } + + return new ActionContext(new DefaultHttpContext(), routeData, new ActionDescriptor()); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/OptionDescriptors/DefaultViewLocationExpanderProviderTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/OptionDescriptors/DefaultViewLocationExpanderProviderTest.cs new file mode 100644 index 0000000000..a73ab403b4 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/OptionDescriptors/DefaultViewLocationExpanderProviderTest.cs @@ -0,0 +1,71 @@ +// 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 Microsoft.Framework.DependencyInjection; +using Microsoft.Framework.OptionsModel; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Razor.OptionDescriptors +{ + public class DefaultViewLocationExpanderProviderTest + { + [Fact] + public void ViewLocationExpanders_ReturnsActivatedListOfExpanders() + { + // Arrange + var service = Mock.Of(); + var expander = Mock.Of(); + var type = typeof(TestViewLocationExpander); + var typeActivator = new TypeActivator(); + var serviceProvider = new Mock(); + serviceProvider.Setup(p => p.GetService(typeof(ITestService))) + .Returns(service); + var options = new RazorViewEngineOptions(); + options.ViewLocationExpanders.Add(type); + options.ViewLocationExpanders.Add(expander); + var accessor = new Mock>(); + accessor.SetupGet(a => a.Options) + .Returns(options); + var provider = new DefaultViewLocationExpanderProvider(accessor.Object, + typeActivator, + serviceProvider.Object); + + // Act + var result = provider.ViewLocationExpanders; + + // Assert + Assert.Equal(2, result.Count); + var testExpander = Assert.IsType(result[0]); + Assert.Same(service, testExpander.Service); + Assert.Same(expander, result[1]); + } + + private class TestViewLocationExpander : IViewLocationExpander + { + public TestViewLocationExpander(ITestService service) + { + Service = service; + } + + public ITestService Service { get; private set; } + + public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable viewLocations) + { + throw new NotImplementedException(); + } + + public void PopulateValues(ViewLocationExpanderContext context) + { + throw new NotImplementedException(); + } + } + + public interface ITestService + { + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/OptionDescriptors/ViewLocationExpanderDescriptorExtensionsTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/OptionDescriptors/ViewLocationExpanderDescriptorExtensionsTest.cs new file mode 100644 index 0000000000..a84e8382af --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/OptionDescriptors/ViewLocationExpanderDescriptorExtensionsTest.cs @@ -0,0 +1,80 @@ +// 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 Microsoft.AspNet.Mvc.Razor.OptionDescriptors; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Razor +{ + public class ViewLocationExpanderDescriptorExtensionsTest + { + [Theory] + [InlineData(-1)] + [InlineData(5)] + public void Insert_WithType_ThrowsIfIndexIsOutOfBounds(int index) + { + // Arrange + var collection = new List + { + new ViewLocationExpanderDescriptor(Mock.Of()), + new ViewLocationExpanderDescriptor(Mock.Of()) + }; + + // Act & Assert + Assert.Throws("index", + () => collection.Insert(index, typeof(IViewLocationExpander))); + } + + [Theory] + [InlineData(-2)] + [InlineData(3)] + public void Insert_WithInstance_ThrowsIfIndexIsOutOfBounds(int index) + { + // Arrange + var collection = new List + { + new ViewLocationExpanderDescriptor(Mock.Of()), + new ViewLocationExpanderDescriptor(Mock.Of()) + }; + var expander = Mock.Of(); + + // Act & Assert + Assert.Throws("index", () => collection.Insert(index, expander)); + } + + [InlineData] + public void ViewLocationExpanderDescriptors_AddsTypesAndInstances() + { + // Arrange + var expander = Mock.Of(); + var type = typeof(TestViewLocationExpander); + var collection = new List(); + + // Act + collection.Add(expander); + collection.Insert(0, type); + + // Assert + Assert.Equal(2, collection.Count); + Assert.IsType(collection[0].Instance); + Assert.Same(expander, collection[0].Instance); + } + + private class TestViewLocationExpander : IViewLocationExpander + { + public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable viewLocations) + { + throw new NotImplementedException(); + } + + public void PopulateValues(ViewLocationExpanderContext context) + { + throw new NotImplementedException(); + } + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/OptionDescriptors/ViewLocationExpanderDescriptorTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/OptionDescriptors/ViewLocationExpanderDescriptorTest.cs new file mode 100644 index 0000000000..515bccd7a1 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/OptionDescriptors/ViewLocationExpanderDescriptorTest.cs @@ -0,0 +1,68 @@ +// 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 Microsoft.AspNet.Testing; +using Xunit; + +namespace Microsoft.AspNet.Mvc.Razor.OptionDescriptors +{ + public class ViewLocationExpanderDescriptorTest + { + [Fact] + public void ConstructorThrows_IfTypeIsNotViewLocationExpander() + { + // Arrange + var viewEngineType = typeof(IViewLocationExpander).FullName; + var type = typeof(string); + var expected = string.Format("The type '{0}' must derive from '{1}'.", + type.FullName, viewEngineType); + + // Act & Assert + ExceptionAssert.ThrowsArgument(() => new ViewLocationExpanderDescriptor(type), "type", expected); + } + + [Fact] + public void ConstructorSetsViewLocationExpanderType() + { + // Arrange + var type = typeof(TestViewLocationExpander); + + // Act + var descriptor = new ViewLocationExpanderDescriptor(type); + + // Assert + Assert.Equal(type, descriptor.OptionType); + Assert.Null(descriptor.Instance); + } + + [Fact] + public void ConstructorSetsViewLocationExpanderAndType() + { + // Arrange + var expander = new TestViewLocationExpander(); + + // Act + var descriptor = new ViewLocationExpanderDescriptor(expander); + + // Assert + Assert.Same(expander, descriptor.Instance); + Assert.Equal(expander.GetType(), descriptor.OptionType); + } + + private class TestViewLocationExpander : IViewLocationExpander + { + public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable viewLocations) + { + throw new NotImplementedException(); + } + + public void PopulateValues(ViewLocationExpanderContext context) + { + throw new NotImplementedException(); + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorViewEngineTest.cs b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorViewEngineTest.cs index e42f5b757b..9dea394ec9 100644 --- a/test/Microsoft.AspNet.Mvc.Razor.Test/RazorViewEngineTest.cs +++ b/test/Microsoft.AspNet.Mvc.Razor.Test/RazorViewEngineTest.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNet.Mvc.Razor.OptionDescriptors; using Microsoft.AspNet.Mvc.Rendering; using Microsoft.AspNet.PipelineCore; using Microsoft.AspNet.Routing; @@ -40,7 +42,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test public void FindView_WithFullPathReturnsNotFound_WhenPathDoesNotMatchExtension(string viewName) { // Arrange - var viewEngine = CreateSearchLocationViewEngineTester(); + var viewEngine = CreateViewEngine(); var context = GetActionContext(_controllerTestContext); // Act @@ -55,7 +57,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test public void FindViewFullPathSucceedsWithCshtmlEnding(string viewName) { // Arrange - var viewEngine = CreateSearchLocationViewEngineTester(); + var viewEngine = CreateViewEngine(); // Append .cshtml so the viewname is no longer invalid viewName += ".cshtml"; var context = GetActionContext(_controllerTestContext); @@ -72,7 +74,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test public void FindPartialView_WithFullPathReturnsNotFound_WhenPathDoesNotMatchExtension(string partialViewName) { // Arrange - var viewEngine = CreateSearchLocationViewEngineTester(); + var viewEngine = CreateViewEngine(); var context = GetActionContext(_controllerTestContext); // Act @@ -87,7 +89,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test public void FindPartialViewFullPathSucceedsWithCshtmlEnding(string partialViewName) { // Arrange - var viewEngine = CreateSearchLocationViewEngineTester(); + var viewEngine = CreateViewEngine(); // Append .cshtml so the viewname is no longer invalid partialViewName += ".cshtml"; var context = GetActionContext(_controllerTestContext); @@ -104,7 +106,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test { // Arrange var searchedLocations = new List(); - var viewEngine = CreateSearchLocationViewEngineTester(); + var viewEngine = CreateViewEngine(); var context = GetActionContext(_areaTestContext); // Act @@ -112,7 +114,8 @@ namespace Microsoft.AspNet.Mvc.Razor.Test // Assert Assert.False(result.Success); - Assert.Equal(new[] { + Assert.Equal(new[] + { "/Areas/foo/Views/bar/partial.cshtml", "/Areas/foo/Views/Shared/partial.cshtml", "/Views/Shared/partial.cshtml", @@ -123,7 +126,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test public void FindPartialViewFailureSearchesCorrectLocationsWithoutAreas() { // Arrange - var viewEngine = CreateSearchLocationViewEngineTester(); + var viewEngine = CreateViewEngine(); var context = GetActionContext(_controllerTestContext); // Act @@ -141,7 +144,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test public void FindViewFailureSearchesCorrectLocationsWithAreas() { // Arrange - var viewEngine = CreateSearchLocationViewEngineTester(); + var viewEngine = CreateViewEngine(); var context = GetActionContext(_areaTestContext); // Act @@ -160,7 +163,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test public void FindViewFailureSearchesCorrectLocationsWithoutAreas() { // Arrange - var viewEngine = CreateSearchLocationViewEngineTester(); + var viewEngine = CreateViewEngine(); var context = GetActionContext(_controllerTestContext); // Act @@ -182,7 +185,7 @@ namespace Microsoft.AspNet.Mvc.Razor.Test var page = Mock.Of(); pageFactory.Setup(p => p.CreateInstance(It.IsAny())) .Returns(Mock.Of()); - var viewEngine = new RazorViewEngine(pageFactory.Object); + var viewEngine = CreateViewEngine(pageFactory.Object); var context = GetActionContext(_controllerTestContext); // Act @@ -194,17 +197,261 @@ namespace Microsoft.AspNet.Mvc.Razor.Test Assert.Equal("/Views/bar/test-view.cshtml", result.ViewName); } - private IViewEngine CreateSearchLocationViewEngineTester() + [Fact] + public void FindView_UsesViewLocationFormat_IfRouteDoesNotContainArea() { + // Arrange var pageFactory = new Mock(); - pageFactory.Setup(vpf => vpf.CreateInstance(It.IsAny())) - .Returns(null); + var page = Mock.Of(); + pageFactory.Setup(p => p.CreateInstance("fake-path1/bar/test-view.rzr")) + .Returns(Mock.Of()) + .Verifiable(); + var viewEngine = new OverloadedLocationViewEngine(pageFactory.Object, + GetViewLocationExpanders(), + GetViewLocationCache()); + var context = GetActionContext(_controllerTestContext); - var viewEngine = new RazorViewEngine(pageFactory.Object); + // Act + var result = viewEngine.FindView(context, "test-view"); + + // Assert + pageFactory.Verify(); + } + + [Fact] + public void FindView_UsesAreaViewLocationFormat_IfRouteContainsArea() + { + // Arrange + var pageFactory = new Mock(); + var page = Mock.Of(); + pageFactory.Setup(p => p.CreateInstance("fake-area-path/foo/bar/test-view2.rzr")) + .Returns(Mock.Of()) + .Verifiable(); + var viewEngine = new OverloadedLocationViewEngine(pageFactory.Object, + GetViewLocationExpanders(), + GetViewLocationCache()); + var context = GetActionContext(_areaTestContext); + + // Act + var result = viewEngine.FindView(context, "test-view2"); + + // Assert + pageFactory.Verify(); + } + + public static IEnumerable FindView_UsesViewLocationExpandersToLocateViewsData + { + get + { + yield return new object[] + { + _controllerTestContext, + new[] + { + "/Views/{1}/{0}.cshtml", + "/Views/Shared/{0}.cshtml" + } + }; + + yield return new object[] + { + _areaTestContext, + new[] + { + "/Areas/{2}/Views/{1}/{0}.cshtml", + "/Areas/{2}/Views/Shared/{0}.cshtml", + "/Views/Shared/{0}.cshtml" + } + }; + } + } + + [Theory] + [MemberData(nameof(FindView_UsesViewLocationExpandersToLocateViewsData))] + public void FindView_UsesViewLocationExpandersToLocateViews(IDictionary routeValues, + IEnumerable expectedSeeds) + { + // Arrange + var pageFactory = new Mock(); + pageFactory.Setup(p => p.CreateInstance("test-string/bar.cshtml")) + .Returns(Mock.Of()) + .Verifiable(); + var expander1Result = new[] { "some-seed" }; + var expander1 = new Mock(); + expander1.Setup(e => e.PopulateValues(It.IsAny())) + .Callback((ViewLocationExpanderContext c) => + { + Assert.NotNull(c.ActionContext); + c.Values["expander-key"] = expander1.ToString(); + }) + .Verifiable(); + expander1.Setup(e => e.ExpandViewLocations(It.IsAny(), + It.IsAny>())) + .Callback((ViewLocationExpanderContext c, IEnumerable seeds) => + { + Assert.NotNull(c.ActionContext); + Assert.Equal(expectedSeeds, seeds); + }) + .Returns(expander1Result) + .Verifiable(); + + var expander2 = new Mock(); + expander2.Setup(e => e.ExpandViewLocations(It.IsAny(), + It.IsAny>())) + .Callback((ViewLocationExpanderContext c, IEnumerable seeds) => + { + Assert.Equal(expander1Result, seeds); + }) + .Returns(new[] { "test-string/{1}.cshtml" }) + .Verifiable(); + + var viewEngine = CreateViewEngine(pageFactory.Object, + new[] { expander1.Object, expander2.Object }); + var context = GetActionContext(routeValues); + + // Act + var result = viewEngine.FindView(context, "test-view"); + + // Assert + Assert.True(result.Success); + Assert.IsAssignableFrom(result.View); + pageFactory.Verify(); + expander1.Verify(); + expander2.Verify(); + } + + [Fact] + public void FindView_CachesValuesIfViewWasFound() + { + // Arrange + var pageFactory = new Mock(); + pageFactory.Setup(p => p.CreateInstance("/Views/bar/baz.cshtml")) + .Verifiable(); + pageFactory.Setup(p => p.CreateInstance("/Views/Shared/baz.cshtml")) + .Returns(Mock.Of()) + .Verifiable(); + var cache = GetViewLocationCache(); + var cacheMock = Mock.Get(cache); + + cacheMock.Setup(c => c.Set(It.IsAny(), "/Views/Shared/baz.cshtml")) + .Verifiable(); + + var viewEngine = CreateViewEngine(pageFactory.Object, cache: cache); + var context = GetActionContext(_controllerTestContext); + + // Act + var result = viewEngine.FindView(context, "baz"); + + // Assert + Assert.True(result.Success); + pageFactory.Verify(); + cacheMock.Verify(); + } + + [Fact] + public void FindView_UsesCachedValueIfViewWasFound() + { + // Arrange + var pageFactory = new Mock(MockBehavior.Strict); + pageFactory.Setup(p => p.CreateInstance("some-view-location")) + .Returns(Mock.Of()) + .Verifiable(); + var expander = new Mock(MockBehavior.Strict); + expander.Setup(v => v.PopulateValues(It.IsAny())) + .Verifiable(); + var cacheMock = new Mock(); + cacheMock.Setup(c => c.Get(It.IsAny())) + .Returns("some-view-location") + .Verifiable(); + + var viewEngine = CreateViewEngine(pageFactory.Object, + new[] { expander.Object }, + cacheMock.Object); + var context = GetActionContext(_controllerTestContext); + + // Act + var result = viewEngine.FindView(context, "baz"); + + // Assert + Assert.True(result.Success); + pageFactory.Verify(); + cacheMock.Verify(); + expander.Verify(); + } + + [Fact] + public void FindView_LooksForViewsIfCachedViewDoesNotExist() + { + // Arrange + var pageFactory = new Mock(); + pageFactory.Setup(p => p.CreateInstance("expired-location")) + .Returns((IRazorPage)null) + .Verifiable(); + pageFactory.Setup(p => p.CreateInstance("some-view-location")) + .Returns(Mock.Of()) + .Verifiable(); + var cacheMock = new Mock(); + cacheMock.Setup(c => c.Get(It.IsAny())) + .Returns("expired-location"); + + var expander = new Mock(); + expander.Setup(v => v.PopulateValues(It.IsAny())) + .Verifiable(); + var expanderResult = new[] { "some-view-location" }; + expander.Setup(v => v.ExpandViewLocations( + It.IsAny(), It.IsAny>())) + .Returns((ViewLocationExpanderContext c, IEnumerable seed) => expanderResult) + .Verifiable(); + + var viewEngine = CreateViewEngine(pageFactory.Object, + new[] { expander.Object }, + cacheMock.Object); + var context = GetActionContext(_controllerTestContext); + + // Act + var result = viewEngine.FindView(context, "baz"); + + // Assert + Assert.True(result.Success); + pageFactory.Verify(); + cacheMock.Verify(); + expander.Verify(); + } + + private IViewEngine CreateViewEngine(IRazorPageFactory pageFactory = null, + IEnumerable expanders = null, + IViewLocationCache cache = null) + { + pageFactory = pageFactory ?? Mock.Of(); + cache = cache ?? GetViewLocationCache(); + var viewLocationExpanderProvider = GetViewLocationExpanders(expanders); + + var viewEngine = new RazorViewEngine(pageFactory, + viewLocationExpanderProvider, + cache); return viewEngine; } + private static IViewLocationExpanderProvider GetViewLocationExpanders( + IEnumerable expanders = null) + { + expanders = expanders ?? Enumerable.Empty(); + var viewLocationExpander = new Mock(); + viewLocationExpander.Setup(v => v.ViewLocationExpanders) + .Returns(expanders.ToList()); + return viewLocationExpander.Object; + } + + private static IViewLocationCache GetViewLocationCache() + { + var cacheMock = new Mock(); + cacheMock.Setup(c => c.Get(It.IsAny())) + .Returns(null); + + return cacheMock.Object; + } + private static ActionContext GetActionContext(IDictionary routeValues, IRazorView razorView = null) { @@ -217,5 +464,31 @@ namespace Microsoft.AspNet.Mvc.Razor.Test var routeData = new RouteData { Values = routeValues }; return new ActionContext(httpContext, routeData, new ActionDescriptor()); } + + private class OverloadedLocationViewEngine : RazorViewEngine + { + public OverloadedLocationViewEngine(IRazorPageFactory pageFactory, + IViewLocationExpanderProvider expanderProvider, + IViewLocationCache cache) + : base(pageFactory, expanderProvider, cache) + { + } + + public override IEnumerable ViewLocationFormats + { + get + { + return new[] { "fake-path1/{1}/{0}.rzr" }; + } + } + + public override IEnumerable AreaViewLocationFormats + { + get + { + return new[] { "fake-area-path/{2}/{1}/{0}.rzr" }; + } + } + } } } diff --git a/test/WebSites/RazorWebSite/Controllers/TemplateExpander.cs b/test/WebSites/RazorWebSite/Controllers/TemplateExpander.cs new file mode 100644 index 0000000000..5695bb9bd9 --- /dev/null +++ b/test/WebSites/RazorWebSite/Controllers/TemplateExpander.cs @@ -0,0 +1,16 @@ +// 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.Mvc; + +namespace RazorWebSite.Controllers +{ + public class TemplateExpander : Controller + { + public ViewResult Index() + { + return View(); + } + } +} \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Services/LanguageViewLocationExpander.cs b/test/WebSites/RazorWebSite/Services/LanguageViewLocationExpander.cs new file mode 100644 index 0000000000..2fa935cec5 --- /dev/null +++ b/test/WebSites/RazorWebSite/Services/LanguageViewLocationExpander.cs @@ -0,0 +1,69 @@ +// 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 Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.Razor; + +namespace RazorWebSite +{ + /// + /// A that replaces adds the language as an extension prefix to view names. + /// + /// + /// For the default case with no areas, views are generated with the following patterns (assuming controller is + /// "Home", action is "Index" and language is "en") + /// Views/Home/en/Action + /// Views/Home/Action + /// Views/Shared/en/Action + /// Views/Shared/Action + /// + public class LanguageViewLocationExpander : IViewLocationExpander + { + private const string ValueKey = "language"; + private readonly Func _valueFactory; + + /// + /// Initializes a new instance of . + /// + /// A factory that provides tbe language to use for expansion. + public LanguageViewLocationExpander(Func valueFactory) + { + _valueFactory = valueFactory; + } + + /// + public void PopulateValues(ViewLocationExpanderContext context) + { + var value = _valueFactory(context.ActionContext); + if (!string.IsNullOrEmpty(value)) + { + context.Values[ValueKey] = value; + } + } + + /// + public virtual IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable viewLocations) + { + string value; + if (context.Values.TryGetValue(ValueKey, out value)) + { + return ExpandViewLocationsCore(viewLocations, value); + } + + return viewLocations; + } + + private IEnumerable ExpandViewLocationsCore(IEnumerable viewLocations, + string value) + { + foreach (var location in viewLocations) + { + yield return location.Replace("{0}", value + "/{0}"); + yield return location; + } + } + } +} \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Startup.cs b/test/WebSites/RazorWebSite/Startup.cs index 63475f6326..8a1e125883 100644 --- a/test/WebSites/RazorWebSite/Startup.cs +++ b/test/WebSites/RazorWebSite/Startup.cs @@ -1,4 +1,6 @@ using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Mvc; +using Microsoft.AspNet.Mvc.Razor; using Microsoft.Framework.DependencyInjection; namespace RazorWebSite @@ -15,6 +17,12 @@ namespace RazorWebSite // Add MVC services to the services container services.AddMvc(configuration); services.AddTransient(); + services.SetupOptions(options => + { + var expander = new LanguageViewLocationExpander( + context => context.HttpContext.Request.Query["language-expander-value"]); + options.ViewLocationExpanders.Add(expander); + }); }); // Add MVC to the request pipeline diff --git a/test/WebSites/RazorWebSite/Views/TemplateExpander/Index.cshtml b/test/WebSites/RazorWebSite/Views/TemplateExpander/Index.cshtml new file mode 100644 index 0000000000..14d0b76305 --- /dev/null +++ b/test/WebSites/RazorWebSite/Views/TemplateExpander/Index.cshtml @@ -0,0 +1,2 @@ +expander-index +@await Html.PartialAsync("_Partial") \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Views/TemplateExpander/_Partial.cshtml b/test/WebSites/RazorWebSite/Views/TemplateExpander/_Partial.cshtml new file mode 100644 index 0000000000..b32b760c40 --- /dev/null +++ b/test/WebSites/RazorWebSite/Views/TemplateExpander/_Partial.cshtml @@ -0,0 +1 @@ +expander-partial \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Views/TemplateExpander/fr/Index.cshtml b/test/WebSites/RazorWebSite/Views/TemplateExpander/fr/Index.cshtml new file mode 100644 index 0000000000..f05e6dc28d --- /dev/null +++ b/test/WebSites/RazorWebSite/Views/TemplateExpander/fr/Index.cshtml @@ -0,0 +1,2 @@ +fr-index +@await Html.PartialAsync("_Partial") \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Views/TemplateExpander/fr/_Partial.cshtml b/test/WebSites/RazorWebSite/Views/TemplateExpander/fr/_Partial.cshtml new file mode 100644 index 0000000000..ceaf0a8514 --- /dev/null +++ b/test/WebSites/RazorWebSite/Views/TemplateExpander/fr/_Partial.cshtml @@ -0,0 +1 @@ +fr-partial \ No newline at end of file diff --git a/test/WebSites/RazorWebSite/Views/TemplateExpander/gb/_Partial.cshtml b/test/WebSites/RazorWebSite/Views/TemplateExpander/gb/_Partial.cshtml new file mode 100644 index 0000000000..caa9c528f3 --- /dev/null +++ b/test/WebSites/RazorWebSite/Views/TemplateExpander/gb/_Partial.cshtml @@ -0,0 +1 @@ +gb-partial \ No newline at end of file