Migrating to .NET Core – Overridable Localization – Handling Argument Count Mismatch

You can read my original post to understand how I implemented overridable Localization when using Razor Class Libraries (RCL). TLDR; My implementation allows you to use ViewLocalization in RCLs and additionally, allows you to have an override pattern for any level of assembly nesting/referencing similar to the pattern Razor View detection uses.

To my implementation, I’ve made two improvements. First, if you have a resource string that has string substitution like "Hi {0}" and you don’t pass in a parameter when requesting that through a localizer, the page immediately stops rendering and just displays whatever was generated up until that point. The debug log reveals the following.

FormatException: Index (zero based) must be greater than or equal to zero and less than the size of the argument list.

This happens in the LocalizedHtmlString.WriteTo method. Since I couldn’t modify/override this code and I definitely didn’t want the site to just drop dead in its tracks when parameter counts were wrong, I updated my IStringLocalizer<T> and IHtmlLocalizer<T> classes (or at least the helper method). In a ‘literal’ implementation, it would look like the following. This code checks to see if there are sufficient arguments passed in, and if there are not, it appends more string arguments simply indicating that that argument is missing. This way the site still renders and the developer can easily see where arguments were forgotten.

public virtual LocalizedHtmlString this[ string name ] => this[ name, new object[] { } ];

public virtual LocalizedHtmlString this[ string name, params object[] arguments ] 
{
    var ls = _localizer[name, arguments];

    if ( !ls.IsResourceNotFound( ls ) )
    {
        var matches = parameterCount.Matches( ls.Value );

        var expectedParameters = matches.Count > 0
            ? matches.Cast<Match>()
                .Max( m => int.Parse( m.Groups[ "number" ].Value ) + 1 )
            : 0;

        if ( arguments.Length < expectedParameters )
        {
            var protectedArgs =
                arguments.Concat(
                    Enumerable
                        .Range( arguments.Length, expectedParameters - arguments.Length )
                        .Select( p => $"{{MISSING PARAM #{p}}}" )
                ).ToArray();

            return _localizer[name, protectedArgs];
        }
    }

    return ls;
}

The second improvement I made was to clean up the code a ton using generics. Read the original article to see mention of not being DRY. But now I am. I won’t explain everything that changed but instead just drop the updated source code for your review.

public class ResourceLocalizer<TLocalizer, TLocalized>
{
	private string _resourcesRelativePath;
	protected TLocalizer[] localizers;

	public ResourceLocalizer( IOptions<Microsoft.Extensions.Localization.LocalizationOptions> localizationOptions )
	{
		_resourcesRelativePath = localizationOptions.Value.ResourcesPath ?? string.Empty;
	}

	protected string BuildBaseSuffix( Type resourceType )
	{
		// Given a type of RootNameSpace.NS1.NS2.Class,
		// The return value will be N1.N2.Class and should be used to create localizers 
		// for each assembly passed in on `LocalizationOptions`.  
		//
		// The caller will then need to call
		//		factory.Create( Assembly1.N1.N2.Class, Assembly1);
		//		factory.Create( Assembly2.N1.N2.Class, Assembly2);
		//		...
		//
		//
		// factory.Create will correctly put in the 'ResourcePath' based on the
		// `ResourceLocationAttribute` of each assembly.  Otherwise it assumes that all
		// assemblies have the same resource path set inside `LocalizationOptions.ResourcesPath`.

		var typeInfo = resourceType.GetTypeInfo();
		var rootNamespace = GetRootNamespace( typeInfo.Assembly );
		var resourcePrefix = GetResourcePath( typeInfo.Assembly ) + ".";

		var baseSuffix = typeInfo.FullName.Substring( rootNamespace.Length + 1 );

		// When a base path is passed into the Factory.Create, it'll preprend the
		// 'resource path' so we need to trim 'resource path' namespace prefix.  This
		// probably only comes into play for a 'shared resource' and if the file/class is
		// placed inside the Resources folder.  The proper way to do it is probably to
		// create a SharedResources class at root of assembly/namespacing, then put a 
		// SharedResources.resx file at root of Resources folder.  But I wanted to 
		// keep that dummy class tucked away.
		if ( baseSuffix.StartsWith( resourcePrefix ) )
		{
			baseSuffix = baseSuffix.Substring( resourcePrefix.Length );
		}

		return baseSuffix;
	}

	private string GetResourcePath( Assembly assembly )
	{
		var resourceLocationAttribute = assembly.GetCustomAttribute<ResourceLocationAttribute>();

		// If we don't have an attribute assume all assemblies use the same resource location.
		var resourceLocation = resourceLocationAttribute == null
			? _resourcesRelativePath
			: resourceLocationAttribute.ResourceLocation + ".";

		resourceLocation = resourceLocation
			.Replace( Path.DirectorySeparatorChar, '.' )
			.Replace( Path.AltDirectorySeparatorChar, '.' );

		return resourceLocation;
	}

	private string GetRootNamespace( Assembly assembly )
	{
		var rootNamespaceAttribute = assembly.GetCustomAttribute<RootNamespaceAttribute>();

		if ( rootNamespaceAttribute != null )
		{
			return rootNamespaceAttribute.RootNamespace;
		}

		return assembly.GetName().Name;
	}

	protected string BuildBaseName( string path, string applicationName )
	{
		var extension = Path.GetExtension( path );
		var startIndex = path[ 0 ] == '/' || path[ 0 ] == '\\' ? 1 : 0;
		var length = path.Length - startIndex - extension.Length;
		var capacity = length + applicationName.Length + 1;
		var builder = new StringBuilder( path, startIndex, length, capacity );

		builder.Replace( '/', '.' ).Replace( '\\', '.' );

		// Prepend the application name
		builder.Insert( 0, '.' );
		builder.Insert( 0, applicationName );

		return builder.ToString();
	}

	protected IEnumerable<LocalizedString> GetAllLocalizedStrings( bool includeParentCultures, Func<TLocalizer, bool, IEnumerable<LocalizedString>> getAllStrings )
	{
		var keys = new HashSet<string>();

		foreach ( var l in localizers )
		{
			var allStrings = getAllStrings( l, includeParentCultures ).GetEnumerator();
			var more = true;

			while ( more )
			{
				try
				{
					more = allStrings.MoveNext();
				}
				catch
				{
					more = false;
				}

				if ( more && !keys.Contains( allStrings.Current.Name ) )
				{
					keys.Add( allStrings.Current.Name );
					yield return allStrings.Current;
				}
			}
		}
	}

	protected TLocalizedReturn GetLocalizedString<TLocalizedReturn>(
		string key,
		object[] arguments,
		Func<TLocalizedReturn, string> getValue,
		Func<TLocalizedReturn, bool> isResourceFound,
		Func<TLocalizer, string, object[], TLocalizedReturn> getLocalized
	)
	{
		if ( key == null )
		{
			throw new ArgumentNullException( nameof( key ) );
		}

		var currentLocalizer = 0;
		var ls = getLocalized( localizers[ currentLocalizer ], key, arguments );

		while ( !isResourceFound( ls ) && currentLocalizer < localizers.Length - 1 )
		{
			currentLocalizer++;
			ls = getLocalized( localizers[ currentLocalizer ], key, arguments );
		}

		return VerifyLocalizedStringArguments<TLocalizedReturn>(
			ls,
			arguments,
			getValue,
			isResourceFound,
			args => getLocalized( localizers[ currentLocalizer ], key, args )
		);
	}

	private static Regex parameterCount = new Regex( @"(?<!\{)\{(?<number>[0-9]+).*?\}(?!\})", RegexOptions.Compiled );
	private TLocalizedReturn VerifyLocalizedStringArguments<TLocalizedReturn>(
		TLocalizedReturn ls,
		object[] arguments,
		Func<TLocalizedReturn, string> getValue,
		Func<TLocalizedReturn, bool> isResourceFound,
		Func<object[], TLocalizedReturn> getLocalized )
	{
		if ( isResourceFound( ls ) )
		{
			// https://stackoverflow.com/a/948316/166231
			var matches = parameterCount.Matches( getValue( ls ) );

			var expectedParameters = matches.Count > 0
				? matches.Cast<Match>()
					.Max( m => int.Parse( m.Groups[ "number" ].Value ) + 1 )
				: 0;

			if ( arguments.Length < expectedParameters )
			{
				var protectedArgs =
					arguments.Concat(
						Enumerable
							.Range( arguments.Length, expectedParameters - arguments.Length )
							.Select( p => $"{{MISSING PARAM #{p}}}" )
					).ToArray();

				return getLocalized( protectedArgs );
			}
		}

		return ls;
	}

	// Helpers to make Generics work
	protected bool IsResourceFound( LocalizedString s ) => !s.ResourceNotFound;
	protected bool IsResourceFound( LocalizedHtmlString s ) => !s.IsResourceNotFound;
	protected string GetValue( LocalizedString s ) => s.Value;
	protected string GetValue( LocalizedHtmlString s ) => s.Value;
	protected LocalizedHtmlString GetLocalizedHtmlItem( IHtmlLocalizer l, string key, object[] arguments ) => l[ key, arguments ];
	protected LocalizedString GetLocalizedStringItem( IHtmlLocalizer l, string key, object[] arguments ) => l.GetString( key, arguments );
	protected LocalizedString GetLocalizedStringItem( IStringLocalizer l, string key, object[] arguments ) => l[ key, arguments ];
}

public class StringLocalizer<TResourceSource> : ResourceLocalizer<IStringLocalizer, LocalizedString>, IStringLocalizer<TResourceSource>
{
	public StringLocalizer(
		IStringLocalizerFactory factory,
		IOptions<Microsoft.Extensions.Localization.LocalizationOptions> coreLocalizationOptions,
		IOptions<LocalizationOptions> localizationOptions ) : base( coreLocalizationOptions )
	{
		if ( factory == null )
		{
			throw new ArgumentNullException( nameof( factory ) );
		}
		if ( localizationOptions == null )
		{
			throw new ArgumentNullException( nameof( localizationOptions ) );
		}

		var baseNameSuffix = BuildBaseSuffix( typeof( TResourceSource ) );

		localizers =
			localizationOptions.Value.AssemblyNames
				.Select( a => factory.Create( $"{a}.{baseNameSuffix}", a ) )
				.ToArray();
	}

	public virtual LocalizedString this[ string name ] => GetLocalizedString( name, new object[] { }, GetValue, IsResourceFound, GetLocalizedStringItem );
	public virtual LocalizedString this[ string name, params object[] arguments ] => GetLocalizedString( name, arguments, GetValue, IsResourceFound, GetLocalizedStringItem );

	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllLocalizedStrings( includeParentCultures, ( l, includeParent ) => l.GetAllStrings( includeParent ) );

	public IStringLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
}

public class HtmlLocalizer<TResourceSource> : ResourceLocalizer<IHtmlLocalizer, LocalizedHtmlString>, IHtmlLocalizer<TResourceSource>
{
	public HtmlLocalizer(
		IHtmlLocalizerFactory factory,
		IOptions<Microsoft.Extensions.Localization.LocalizationOptions> coreLocalizationOptions,
		IOptions<LocalizationOptions> localizationOptions ) : base( coreLocalizationOptions )
	{
		var baseNameSuffix = BuildBaseSuffix( typeof( TResourceSource ) );

		localizers =
			localizationOptions.Value.AssemblyNames
				.Select( a => factory.Create( baseNameSuffix, a ) )
				.ToArray();
	}

	public virtual LocalizedHtmlString this[ string name ] => GetLocalizedString( name, new object[] { }, GetValue, IsResourceFound, GetLocalizedHtmlItem );
	public virtual LocalizedHtmlString this[ string name, params object[] arguments ] => GetLocalizedString( name, arguments, GetValue, IsResourceFound, GetLocalizedHtmlItem );

	public LocalizedString GetString( string name ) => GetLocalizedString( name, new object[] { }, GetValue, IsResourceFound, GetLocalizedStringItem );
	public LocalizedString GetString( string name, params object[] arguments ) => GetLocalizedString( name, arguments, GetValue, IsResourceFound, GetLocalizedStringItem );

	public IHtmlLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllLocalizedStrings( includeParentCultures, ( l, includeParent ) => l.GetAllStrings( includeParent ) );
}

public class ViewLocalizer : ResourceLocalizer<IHtmlLocalizer, LocalizedHtmlString>, IViewLocalizer, IViewContextAware
{
	private readonly IHtmlLocalizerFactory localizerFactory;
	private readonly LocalizationOptions localizationOptions;

	public ViewLocalizer(
		IHtmlLocalizerFactory localizerFactory,
		IOptions<Microsoft.Extensions.Localization.LocalizationOptions> coreLocalizationOptions,
		IOptions<LocalizationOptions> localizationOptions
	) : base( coreLocalizationOptions )
	{
		if ( localizerFactory == null )
		{
			throw new ArgumentNullException( nameof( localizerFactory ) );
		}

		if ( localizationOptions == null )
		{
			throw new ArgumentNullException( nameof( localizationOptions ) );
		}

		this.localizationOptions = localizationOptions.Value;
		this.localizerFactory = localizerFactory;
	}

	public void Contextualize( ViewContext viewContext )
	{
		if ( viewContext == null )
		{
			throw new ArgumentNullException( nameof( viewContext ) );
		}

		// Given a view path "/Views/Home/Index.cshtml" we want a baseName like "MyApplication.Views.Home.Index"
		var path = viewContext.ExecutingFilePath;

		if ( string.IsNullOrEmpty( path ) )
		{
			path = viewContext.View.Path;
		}

		Debug.Assert( !string.IsNullOrEmpty( path ), "Couldn't determine a path for the view" );

		localizers =
			localizationOptions.AssemblyNames
				.Select( a => localizerFactory.Create( BuildBaseName( path, a ), a ) )
				.ToArray();
	}

	public virtual LocalizedHtmlString this[ string name ] => GetLocalizedString( name, new object[] { }, GetValue, IsResourceFound, GetLocalizedHtmlItem );
	public virtual LocalizedHtmlString this[ string name, params object[] arguments ] => GetLocalizedString( name, arguments, GetValue, IsResourceFound, GetLocalizedHtmlItem );

	public LocalizedString GetString( string name ) => GetLocalizedString( name, new object[] { }, GetValue, IsResourceFound, GetLocalizedStringItem );
	public LocalizedString GetString( string name, params object[] arguments ) => GetLocalizedString( name, arguments, GetValue, IsResourceFound, GetLocalizedStringItem );

	public IHtmlLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllLocalizedStrings( includeParentCultures, ( l, includeParent ) => l.GetAllStrings( includeParent ) );
}

Migrating to .NET Core – Overridable Localization in Razor Class Libraries

I have to admit, the possibility I was most excited about when migrating to .NET Core Razor Pages projects from my 20 year old code base in WebForms was the use of Razor Class Libraries. Packaging up reusable UI components and their required resources into a single project sounded like bliss.

Previously, I had two problems that Razor Class Libraries were going to overcome.

  1. For shared ‘resources’ (css, js, images, etc.) I had a single Git repository that was nested under the main site projects and just included as regular source files. So, while I had (more or less) one code base, I had to make sure I pulled it every time I worked in a different site. I know there is some sort of feature with Git and nested or linked repositories, but I vaguely recall reading some articles about people complaining about them not functioning as advertised, so I steered clear.
  2. The other problem was UI components. Currently, I was creating all my shareable/core ‘controls/UI’ via C# and control creation inside a CreateChildControls method. While it was not horrible (after making some extension methods to make it a little more fluent), it definitely wasn’t as nice as working in HTML/Razor markup.

However, after getting my POC wired up, I came across this line of code in my old WebForms ascx:

<h1><%# GetString( "Default.lnkModeler" ) %></h1>

GetString was a helper method I wrote that wrapped multiple ResourceManager objects into a single instance and pulled the string from the ResourceManager with the highest precedence: Client Site > Infrastructure.UI > Services and so on.  Every level could have a resource file and no matter which level GetString was called, it would find the correct (possibly overridden) occurrence.  This allowed the client site to override any message it wanted to that came from the core (most common) implementation.

Implementing Overridable Localization

While learning ASP.NET Core, I figured I better do Localization ‘right’.  In my WebForms framework, each ‘level’ had only a single ResourceStrings.resx file.  However, in my Razor Page project, I wanted to leverage the LocalizationOptions.ResourcePath property and use the ViewLocalization class to find *.resx files matching my Razor Page names.  Ryan Brandenburg, of ASP.NET team, mentioned the following in a bug report talking about ViewLocalization not working in external assembly: 

We think the correct way to go about this would be to get a IStringLocalizer from some method internal to the library hosting the views, so:
@ExternalLibrary.GetLocalizer(viewPath) instead of @inject Microsoft.AspNetCore.Mvc.Localization.IViewLocalizer Localizer. If it’s very important to you that this come from the IViewLocalizer for some reason you’ll have to write your own implementation.

Well, it was important to me, or at least important that I tried.  Your mileage may vary, but I was able to make ViewLocalizer, StringLocalizer<T>, and HtmlLocalizer<T> all behave the way I mentioned above.

The two problems to overcome were:

  1. Using the correct Assembly when trying to find the resource.  This is the main problem with ViewLocalizer injection used in views from Razor Class Libraries.  No matter which project the ViewLocalizer is injected in, it sets the Assembly to hostingEnvironment.ApplicationName.  When using a Razor Class Library (or any external library), the Assembly needs to be the assembly attempting to use ViewLocalizer.
  2. Overriding localization at any level.  Although this was a custom feature to our framework, it is basically the same pattern used in finding a Razor page from a Razor Class Library.  If the page is found in ‘same location’ in containing site, it uses that, otherwise it uses the page from the Razor Class Library.  To make this work, we simply required all localization points to contain a list of ‘localizers’ that it can try to find the desired string in instead of a single one from the containing site.

Using the Correct Assembly

For all my implementations, I simply introduced a LocalizationOptions that contained a string array of AssemblyNames that are configured during ConfigureServices defining all the assemblies to search.  In the example below, my ‘framework’ hierarchy is Client refs Template refs Infrastructure.

services.Configure<Infrastructure.Localization.LocalizationOptions>(
	options => options.AssemblyNames = new[] {
		hostingEnvironment.ApplicationName, // client
		new AssemblyName( typeof( Startup ).Assembly.FullName ).Name, // template
		new AssemblyName( typeof( Infrastructure.Localization.LocalizationOptions ).Assembly.FullName ).Name  // infrastructure
	}
);

Once the assembly names were in place, making the ViewLocalizer use the correct assembly wasn’t that bad.  I implemented the IViewContextAware.Contextualize method as the following (pretty much verbatim from ASP.NET Core ViewLocalizer implementation except for the assignment of htmlLocalizers which used each assembly name instead of the single hostingEnvironment.ApplicationName) :

public void Contextualize( ViewContext viewContext )
{
	if ( viewContext == null )
	{
		throw new ArgumentNullException( nameof( viewContext ) );
	}

	// Given a view path "/Views/Home/Index.cshtml" we want a baseName like "MyApplication.Views.Home.Index"
	var path = viewContext.ExecutingFilePath;

	if ( string.IsNullOrEmpty( path ) )
	{
		path = viewContext.View.Path;
	}

	Debug.Assert( !string.IsNullOrEmpty( path ), "Couldn't determine a path for the view" );

	htmlLocalizers =
		localizationOptions.AssemblyNames
			.Select( a => localizerFactory.Create( BuildBaseName( path, a ), a ) )
			.ToArray();
}

For StringLocalizer<T> and HtmlLocalizer<T> I had to steal some code from ResourceManagerStringLocalizerFactory. Both of these classes need to build a ‘localizer’ in their constructor based on TResourceSource. When TResourceSource is passed in, I need to get the class name (including any namespacing) but exclude the root namespace of the containing Assembly. This is where ResourceManagerStringLocalizerFactory.GetRootNamespace comes in. Here is my code to get the class ‘name’ excluding the assembly namespace.

protected string BuildBaseSuffix( Type resourceType )
{
	// Given a type of RootNameSpace.NS1.NS2.Class,
	// The return value will be N1.N2.Class and should be used to create localizers 
	// for each assembly passed in on `LocalizationOptions`.  
	//
	// The caller will then need to call
	//		factory.Create( Assembly1.N1.N2.Class, Assembly1);
	//		factory.Create( Assembly2.N1.N2.Class, Assembly2);
	//		...
	//
	//
	// factory.Create will correctly put in the 'ResourcePath' based on the
	// `ResourceLocationAttribute` of each assembly.  Otherwise it assumes that all
	// assemblies have the same resource path set inside `LocalizationOptions.ResourcesPath`.

	var typeInfo = resourceType.GetTypeInfo();
	var rootNamespace = GetRootNamespace( typeInfo.Assembly );
	return typeInfo.FullName.Substring( rootNamespace.Length + 1 );
}

private string GetRootNamespace( Assembly assembly )
{
	var rootNamespaceAttribute = assembly.GetCustomAttribute<RootNamespaceAttribute>();

	if ( rootNamespaceAttribute != null )
	{
		return rootNamespaceAttribute.RootNamespace;
	}

	return assembly.GetName().Name;
}

Then, similar to the ViewLocalizer, inside the constructors of both these generic classes, their localizers are created via appending each assembly name to the base suffix name:

var baseNameSuffix = BuildBaseSuffix( typeof( TResourceSource ) );

localizers =
	assemblyNames
		.Select( a => factory.Create( $"{a}.{baseNameSuffix}", a ) )
		.ToArray();

Applying Localizer Precedence

Now that my localizers were initialized properly, returning the requested string is pretty straightforward. I just loop through the list of localizers until I get a hit, otherwise return the last attempted LocalizedString – where the value will just default to the key/name passed in.

protected LocalizedString GetLocalizedString( IStringLocalizer[] stringLocalizers, string key, object[] arguments )
{
	if ( key == null )
	{
		throw new ArgumentNullException( nameof( key ) );
	}

	var ls = stringLocalizers[ 0 ][ key, arguments ];
	var currentLocalizer = 1;

	while ( ls.ResourceNotFound && currentLocalizer < stringLocalizers.Length )
	{
		ls = stringLocalizers[ currentLocalizer ][ key, arguments ];
		currentLocalizer++;
	}

	return ls;
}

Performance Concerns

I am using as much of the code from the framework as possible, so don’t think I will be causing any additional overhead except for obviously loading a localizer for each assembly name passed in. However, I was a bit surprised that the internal localizer cache inside ResourceManagerStringLocalizerFactory used different methods of generating a cache key based on Create( Type ) vs Create( string, string ) method calls. That said, assuming the overhead of attempting to find a resource, and not finding a match, isn’t too expensive (i.e. when the client assembly doesn’t override the requested string), this should be a straight forward implementation to allow you to use ViewLocalizer in Razor Class Library projects mimicking the same pattern of a client Page/Partial overriding a library Page/Partial.

Dependency Injection Requirements

In addition to the built-in UseLocalization and UseViewLocalization (or UseMvcLocalization which I am not using…yet), you need to use the newly created classes.

services.TryAddTransient( typeof( IStringLocalizer<> ), typeof( Infrastructure.Localization.StringLocalizer<> ) );
services.TryAddTransient( typeof( IHtmlLocalizer<> ), typeof( Infrastructure.Localization.HtmlLocalizer<> ) );
services.TryAddTransient( typeof( IViewLocalizer ), typeof( Infrastructure.Localization.ViewLocalizer ) );

Below you will find my final source code.  I wanted to keep all the ‘weird’ logic in one file, so all my classes derive from a single ResourceLocalizer.  Unfortunately, with no shared interfaces or base classes between StringLocalizer and HtmlLocalizer I couldn’t think of a clever way to keep the code completely DRY.  I have identical code for GetLocalizedString and GetAllStrings except for the parameter type passed in.

Drop a comment if you have any concerns, improvements, or questions…I’m still learning.

public class ResourceLocalizer
{
	protected string BuildBaseSuffix( Type resourceType )
	{
		// Given a type of RootNameSpace.NS1.NS2.Class,
		// The return value will be N1.N2.Class and should be used to create localizers 
		// for each assembly passed in on `LocalizationOptions`.  
		//
		// The caller will then need to call
		//		factory.Create( Assembly1.N1.N2.Class, Assembly1);
		//		factory.Create( Assembly2.N1.N2.Class, Assembly2);
		//		...
		//
		//
		// factory.Create will correctly put in the 'ResourcePath' based on the
		// `ResourceLocationAttribute` of each assembly.  Otherwise it assumes that all
		// assemblies have the same resource path set inside `LocalizationOptions.ResourcesPath`.

		var typeInfo = resourceType.GetTypeInfo();
		var rootNamespace = GetRootNamespace( typeInfo.Assembly );
		return typeInfo.FullName.Substring( rootNamespace.Length + 1 );
	}

	private string GetRootNamespace( Assembly assembly )
	{
		var rootNamespaceAttribute = assembly.GetCustomAttribute<RootNamespaceAttribute>();

		if ( rootNamespaceAttribute != null )
		{
			return rootNamespaceAttribute.RootNamespace;
		}

		return assembly.GetName().Name;
	}

	protected string BuildBaseName( string path, string applicationName )
	{
		var extension = Path.GetExtension( path );
		var startIndex = path[ 0 ] == '/' || path[ 0 ] == '\\' ? 1 : 0;
		var length = path.Length - startIndex - extension.Length;
		var capacity = length + applicationName.Length + 1;
		var builder = new StringBuilder( path, startIndex, length, capacity );

		builder.Replace( '/', '.' ).Replace( '\\', '.' );

		// Prepend the application name
		builder.Insert( 0, '.' );
		builder.Insert( 0, applicationName );

		return builder.ToString();
	}

	protected IEnumerable<LocalizedString> GetAllStrings( IHtmlLocalizer[] htmlLocalizers, bool includeParentCultures )
	{
		var keys = new HashSet<string>();

		foreach ( var l in htmlLocalizers )
		{
			foreach ( var s in l.GetAllStrings( includeParentCultures ) )
			{
				if ( !keys.Contains( s.Name ) )
				{
					yield return s;
				}
			}
		}
	}

	protected LocalizedHtmlString GetLocalizedHtmlString( IHtmlLocalizer[] htmlLocalizers, string key, object[] arguments )
	{
		if ( key == null )
		{
			throw new ArgumentNullException( nameof( key ) );
		}

		var lhs = htmlLocalizers[ 0 ][ key, arguments ];
		var currentLocalizer = 1;

		while ( lhs.IsResourceNotFound && currentLocalizer < htmlLocalizers.Length )
		{
			lhs = htmlLocalizers[ currentLocalizer ][ key, arguments ];
			currentLocalizer++;
		}

		return lhs;
	}

	protected LocalizedString GetLocalizedString( IHtmlLocalizer[] htmlLocalizers, string key, object[] arguments )
	{
		if ( key == null )
		{
			throw new ArgumentNullException( nameof( key ) );
		}

		var ls = htmlLocalizers[ 0 ].GetString( key, arguments );
		var currentLocalizer = 1;

		while ( ls.ResourceNotFound && currentLocalizer < htmlLocalizers.Length )
		{
			ls = htmlLocalizers[ currentLocalizer ].GetString( key, arguments );
			currentLocalizer++;
		}

		return ls;
	}

	protected IEnumerable<LocalizedString> GetAllStrings( IStringLocalizer[] htmlLocalizers, bool includeParentCultures )
	{
		var keys = new HashSet<string>();

		foreach ( var l in htmlLocalizers )
		{
			foreach ( var s in l.GetAllStrings( includeParentCultures ) )
			{
				if ( !keys.Contains( s.Name ) )
				{
					yield return s;
				}
			}
		}
	}

	protected LocalizedString GetLocalizedString( IStringLocalizer[] stringLocalizers, string key, object[] arguments )
	{
		if ( key == null )
		{
			throw new ArgumentNullException( nameof( key ) );
		}

		var ls = stringLocalizers[ 0 ][ key, arguments ];
		var currentLocalizer = 1;

		while ( ls.ResourceNotFound && currentLocalizer < stringLocalizers.Length )
		{
			ls = stringLocalizers[ currentLocalizer ][ key, arguments ];
			currentLocalizer++;
		}

		return ls;
	}
}

public class ViewLocalizer : ResourceLocalizer, IViewLocalizer, IViewContextAware
{
	private readonly IHtmlLocalizerFactory localizerFactory;
	private readonly LocalizationOptions localizationOptions;
	private IHtmlLocalizer[] htmlLocalizers;

	public ViewLocalizer( IHtmlLocalizerFactory localizerFactory, IOptions<LocalizationOptions> localizationOptions )
	{
		if ( localizerFactory == null )
		{
			throw new ArgumentNullException( nameof( localizerFactory ) );
		}

		if ( localizationOptions == null )
		{
			throw new ArgumentNullException( nameof( localizationOptions ) );
		}

		this.localizationOptions = localizationOptions.Value;
		this.localizerFactory = localizerFactory;
	}

	public void Contextualize( ViewContext viewContext )
	{
		if ( viewContext == null )
		{
			throw new ArgumentNullException( nameof( viewContext ) );
		}

		// Given a view path "/Views/Home/Index.cshtml" we want a baseName like "MyApplication.Views.Home.Index"
		var path = viewContext.ExecutingFilePath;

		if ( string.IsNullOrEmpty( path ) )
		{
			path = viewContext.View.Path;
		}

		Debug.Assert( !string.IsNullOrEmpty( path ), "Couldn't determine a path for the view" );

		htmlLocalizers =
			localizationOptions.AssemblyNames
				.Select( a => localizerFactory.Create( BuildBaseName( path, a ), a ) )
				.ToArray();
	}

	public virtual LocalizedHtmlString this[ string key ] => GetLocalizedHtmlString( htmlLocalizers, key, new object[] { } );
	public virtual LocalizedHtmlString this[ string key, params object[] arguments ] => GetLocalizedHtmlString( htmlLocalizers, key, arguments );

	public LocalizedString GetString( string name ) => GetLocalizedString( htmlLocalizers, name, new object[] { } );
	public LocalizedString GetString( string name, params object[] values ) => GetLocalizedString( htmlLocalizers, name, values );

	public IHtmlLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllStrings( htmlLocalizers, includeParentCultures );
}

public class StringLocalizer<TResourceSource> : ResourceLocalizer, IStringLocalizer<TResourceSource>
{
	private IStringLocalizer[] localizers;

	public StringLocalizer(
		IStringLocalizerFactory factory,
		IOptions<LocalizationOptions> localizationOptions )
	{
		if ( factory == null )
		{
			throw new ArgumentNullException( nameof( factory ) );
		}
		if ( localizationOptions == null )
		{
			throw new ArgumentNullException( nameof( localizationOptions ) );
		}

		var baseNameSuffix = BuildBaseSuffix( typeof( TResourceSource ) );

		localizers =
			localizationOptions.Value.AssemblyNames
				.Select( a => factory.Create( $"{a}.{baseNameSuffix}", a ) )
				.ToArray();
	}

	public virtual LocalizedString this[ string name ] => GetLocalizedString( localizers, name, new object[] { } );
	public virtual LocalizedString this[ string name, params object[] arguments ] => GetLocalizedString( localizers, name, arguments );

	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllStrings( localizers, includeParentCultures );

	public IStringLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
}

public class HtmlLocalizer<TResourceSource> : ResourceLocalizer, IHtmlLocalizer<TResourceSource>
{
	private IHtmlLocalizer[] htmlLocalizers;

	public HtmlLocalizer(
		IHtmlLocalizerFactory factory,
		IOptions<LocalizationOptions> localizationOptions )
	{
		var baseNameSuffix = BuildBaseSuffix( typeof( TResourceSource ) );

		htmlLocalizers =
			localizationOptions.Value.AssemblyNames
				.Select( a => factory.Create( $"{a}.{baseNameSuffix}", a ) )
				.ToArray();
	}

	public virtual LocalizedHtmlString this[ string name ] => GetLocalizedHtmlString( htmlLocalizers, name, new object[] { } );
	public virtual LocalizedHtmlString this[ string name, params object[] arguments ] => GetLocalizedHtmlString( htmlLocalizers, name, arguments );

	public LocalizedString GetString( string name ) => GetLocalizedString( htmlLocalizers, name, new object[] { } );
	public LocalizedString GetString( string name, params object[] values ) => GetLocalizedString( htmlLocalizers, name, values );

	public IHtmlLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllStrings( htmlLocalizers, includeParentCultures );
}

Migrating to .NET Core–Bootstrap Active Link TagHelper–ActivePattern Update

In my previous post about my Bootstrap Active Link TagHelper, I described using a `active-prefix` properties to determine if a ‘parent’ menu should be flagged active if one of its child menus were active.  This had a couple flaws.

Shared Pages

If you have a Razor View that serves up content for multiple pages, this can be problematic.  Previously, I was comparing the `asp-page` value with the `ViewContextData.ActionDescriptor.DisplayName` value.  However, if you have multiple menus built using the same page (but different route values) this is a problem.  Consider the following:

<bs-menu-link asp-page="/Schedules/Descriptions" asp-route-group="Junior"  menu-text="Juniors"></bs-menu-link>
<bs-menu-link asp-page="/Schedules/Descriptions" asp-route-group="Adult" menu-text="Adults"></bs-menu-link>
<bs-menu-link asp-page="/Schedules/Descriptions" asp-route-group="Senior" menu-text="Seniors"></bs-menu-link>

As you can see, when *any* of these menus are clicked, the `asp-page` and the `ViewContextData.ActionDescriptor.DisplayName` values are going to be “/Schedules/Descriptions” for every one resulting in all three being flagged as active.

Route Values

Before, I was using something similar to the following to determine if a ‘parent’ page was active.

var currentPage = ViewContextData.ActionDescriptor.DisplayName;
var active = currentPage.StartsWith( ActivePrefix, StringComparison.InvariantCultureIgnoreCase );

As you can guess, route values are not considered on this value.  So I had to use something like this:

var active = ViewContextData.HttpContext.Request.Path.StartsWith( ActivePrefix );

Route Value in Middle of Url

The final problem I discovered, is that I changed my route values and Urls so that a ‘prefix’ no longer worked.  For example:

Route: /Schedules/Program/{id}/{location}

Request.Path: /Schedules/Program/Junior.JD/Outdoor

To highlight the ‘Juniors’ menu link, I could have used an `active-prefix` of ‘/Schedules/Program/Junior’ and it would have matched all ‘program’ Urls.  However, I want the Juniors link to also highlight when Request.Path is /Schedules/Descriptions/Junior.

Regex to the Rescue

To solve my problem, I changed `ActivePrefix` to `ActivePattern` and perform a Regex match on the Request.Path.  Additionally, to solve the ‘Shared Pages’ problem, if there is an ActivePattern property specified, I do not check for the `asp-page` being the same as the `ViewContextData.ActionDescriptor.DisplayName`.

To accomplish the same menu above with the new syntax, I would do something like this:

<bs-menu-link active-pattern="\/Schedules\/.*\/Junior" asp-page="/Schedules/Descriptions" asp-route-group="Junior"  menu-text="Juniors"></bs-menu-link>
<bs-menu-link active-pattern="\/Schedules\/.*\/Adult" asp-page="/Schedules/Descriptions" asp-route-group="Adult" menu-text="Adults"></bs-menu-link>
<bs-menu-link active-pattern="\/Schedules\/.*\/Senior" asp-page="/Schedules/Descriptions" asp-route-group="Senior" menu-text="Seniors"></bs-menu-link>

And finally, the full source code:

	[HtmlTargetElement( "bs-menu-link" )]
	public class BootstrapMenuLinkTagHelper : TagHelper
	{
		private readonly IHtmlGenerator htmlGenerator;

		public BootstrapMenuLinkTagHelper( IHtmlGenerator htmlGenerator )
		{
			this.htmlGenerator = htmlGenerator;
		}

		[HtmlAttributeName( "menu-text" )]
		public string MenuText { get; set; }
		[HtmlAttributeName( "active-pattern" )]
		public string ActivePattern { get; set; }

		[ViewContext]
		[HtmlAttributeNotBound]
		public ViewContext ViewContextData { get; set; }

		#region - Properties shared with AnchorTagHelper -
		private const string ActionAttributeName = "asp-action";
		private const string ControllerAttributeName = "asp-controller";
		private const string AreaAttributeName = "asp-area";
		private const string PageAttributeName = "asp-page";
		private const string PageHandlerAttributeName = "asp-page-handler";
		private const string FragmentAttributeName = "asp-fragment";
		private const string HostAttributeName = "asp-host";
		private const string ProtocolAttributeName = "asp-protocol";
		private const string RouteAttributeName = "asp-route";
		private const string RouteValuesDictionaryName = "asp-all-route-data";
		private const string RouteValuesPrefix = "asp-route-";

		private IDictionary<string, string> _routeValues;

		[HtmlAttributeName( ActionAttributeName )]
		public string Action { get; set; }
		[HtmlAttributeName( ControllerAttributeName )]
		public string Controller { get; set; }
		[HtmlAttributeName( AreaAttributeName )]
		public string Area { get; set; }
		[HtmlAttributeName( PageAttributeName )]
		public string Page { get; set; }
		[HtmlAttributeName( PageHandlerAttributeName )]
		public string PageHandler { get; set; }
		[HtmlAttributeName( ProtocolAttributeName )]
		public string Protocol { get; set; }
		[HtmlAttributeName( HostAttributeName )]
		public string Host { get; set; }
		[HtmlAttributeName( FragmentAttributeName )]
		public string Fragment { get; set; }
		[HtmlAttributeName( RouteAttributeName )]
		public string Route { get; set; }
		[HtmlAttributeName( RouteValuesDictionaryName, DictionaryAttributePrefix = RouteValuesPrefix )]
		public IDictionary<string, string> RouteValues
		{
			get
			{
				if ( _routeValues == null )
				{
					_routeValues = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
				}

				return _routeValues;
			}
			set
			{
				_routeValues = value;
			}
		}
		#endregion

		public async override Task ProcessAsync( TagHelperContext context, TagHelperOutput output )
		{
			output.TagName = "li";

			if ( MenuText != null )
			{
				var currentPage = ViewContextData.ActionDescriptor.DisplayName;

				// TODO: Might need to add logic for Areas, but not using yet, so leaving as is.
				var isPageActive =
					( !string.IsNullOrEmpty( Page ) && string.IsNullOrEmpty( ActivePattern ) && string.Compare( currentPage, Page, true ) == 0 ) ||
					( !string.IsNullOrEmpty( ActivePattern ) && new Regex( ActivePattern, RegexOptions.IgnoreCase ).IsMatch( ViewContextData.HttpContext.Request.Path ) );

				// TODO: Only using Razor Pages right now, so this logic might not be exactly right
				var isControllerActive =
					string.IsNullOrEmpty( Page ) &&
					!string.IsNullOrWhiteSpace( Controller ) &&
					string.Compare( Controller, (string)ViewContextData.RouteData.Values[ "controller" ], true ) == 0 &&
					string.Compare( Action, (string)ViewContextData.RouteData.Values[ "action" ], true ) == 0;

				if ( isPageActive || isControllerActive )
				{
					if ( output.Attributes.ContainsName( "class" ) )
					{
						output.Attributes.SetAttribute( "class", <pre wp-pre-tag-4=""></pre>quot;{output.Attributes[ "class" ].Value} active" );
					}
					else
					{
						output.Attributes.SetAttribute( "class", "active" );
					}
				}

				if ( !string.IsNullOrWhiteSpace( Page ) || !string.IsNullOrWhiteSpace( Controller ) )
				{
					// Declaring and using a builtin AnchorTagHelper following patterns from:
					// https://stackoverflow.com/a/56910392/166231
					var anchorTagHelper = new AnchorTagHelper( htmlGenerator )
					{
						Action = Action,
						Area = Area,
						Controller = Controller,
						Page = Page,
						Fragment = Fragment,
						Host = Host,
						Protocol = Protocol,
						Route = Route,
						PageHandler = PageHandler,
						RouteValues = RouteValues,
						ViewContext = ViewContextData
					};

					var anchorOutput =
						new TagHelperOutput( "a",
							new TagHelperAttributeList(),
							( useCachedResult, encoder ) =>
								Task.Factory.StartNew<TagHelperContent>( () => new DefaultTagHelperContent() )
						);

					anchorOutput.Content.AppendHtml( MenuText );

					var anchorAttributes = new[]
					{
					ActionAttributeName,
					ControllerAttributeName,
					AreaAttributeName,
					PageAttributeName,
					PageHandlerAttributeName,
					FragmentAttributeName,
					HostAttributeName,
					ProtocolAttributeName,
					RouteAttributeName,
					RouteValuesDictionaryName
				};

					// TODO: Not sure if I have to pass in all these attributes since they are assigned on the anchorTagHelper
					//		 I asked a question to the Stack Overflow post and am awaiting an answer.
					var anchorAttributeList =
						context.AllAttributes
							.Where( a => anchorAttributes.Contains( a.Name ) || a.Name.StartsWith( RouteValuesPrefix, StringComparison.InvariantCultureIgnoreCase ) )
							.Select( a => new TagHelperAttribute( a.Name, a.Value ) );

					var anchorContext = new TagHelperContext(
						new TagHelperAttributeList( anchorAttributeList ),
						new Dictionary<object, object>(),
						Guid.NewGuid().ToString() );

					await anchorTagHelper.ProcessAsync( anchorContext, anchorOutput );

					output.PreContent.SetHtmlContent( anchorOutput );
				}
				else
				{
					output.PreContent.SetHtmlContent( <pre wp-pre-tag-4=""></pre>quot;<div>{MenuText}</div>" );
				}
			}
		}
	}