Improving IntelliSense for the asp-page Tag Helper using T4 Templates
I find ASP.NET Tag Helpers super helpful. They provide powerful features and yet use clean markup. As an example, here's a tag helper that creates a link to an action method in some controller:
<a asp-controller="Contact" asp-action="Index">Get in Touch</a>
At run-time, ASP.NET then generates the correct URL for this action method and creates an href attribute on the anchor for it:
<a href="/contact">Get in Touch</a>
What's nice about this particular example is that you get help from IntelliSense to pick the correct controller, action method and area:
This also makes your code more robust as a refactoring to a new name will now also change the name in the anchor, or at least flag it as a potential problem.
There are many tag helpers that extend many existing HTML elements; more info can be found here:
- https://docs.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-5.0
- https://docs.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/built-in/?view=aspnetcore-5.0
When rebuilding imar.spaanjaars.com, I changed many endpoints from a controller action method with a view to a simple Razor page as most of them are simply just a route combined with some plain HTML and just a tiny bit of code. For example, all content under the about section is implemented using simple Razor pages that exist on disk. To link to a Razor page, you can use the asp-page tag helper, like this:
<a asp-page="">Page here</a>
But when you use this tag helper you'll notice you don't get help from IntelliSense to pick the correct page. For quite some time, I have been using a solution described by Kurt Dowswell that uses a T4 template to create a class with constants for every page in your Pages folder. Then in your asp-page tag helper, you can link to that constant. You find the article I used here: https://kurtdowswell.com/software-development/razor-pages-url-tt-file/ and you're encouraged to read it to understand the solution. With Kurt's solution, you end up with a class that looks as follows:
public static class SitePages { public static string _ViewImports => @"_ViewImports"; public static string _ViewStart => @"_ViewStart"; public static string About_AboutLayout => @"About/_AboutLayout"; public static string AboutAboutMe => @"/About/AboutMe"; public static string AboutAboutMyBooksBeginningAspNet35 => @"/About/AboutMyBooks/BeginningAspNet35"; public static string AboutAboutMyBooksBeginningAspNet4 => @"/About/AboutMyBooks/BeginningAspNet4"; public static string AboutAboutMyBooksBeginningAspNet45 => @"/About/AboutMyBooks/BeginningAspNet45"; public static string AboutAboutMyBooksBeginningAspNet451 => @"/About/AboutMyBooks/BeginningAspNet451"; ... }
And then you link to your pages using the asp-page tag helper like this:
This works really well and now gives you a much easier way to pick pages in your site.However, when you have a lot of pages in nested sub folders, the long, flat list with results becomes pretty unwieldy pretty quickly. Also, I usually want to skip the pages that start with an underscore as they are not pages you browse to directly normally. To solve these two problems, I have taken Kurt's code and updated it to recursively loop through my Pages folder and create a nested class with constants for every folder it encounters. It also skips files that start with an underscore and handles indenting so regenerating the file when there aren't any new or changed pages doesn't mark the file as dirty.
With my updated template, the generated code now looks like this:
public static class SitePages { public class About { public class AboutMyBooks { public const string BeginningAspNet35 = @"/About/AboutMyBooks/BeginningAspNet35"; public const string BeginningAspNet4 = @"/About/AboutMyBooks/BeginningAspNet4"; ... } public const string AboutMe = @"/About/AboutMe"; ... } public class Account { public const string Login = @"/Account/Login"; public const string Logout = @"/Account/Logout"; ... } }
And it now shows up like this in IntelliSense:
You find the full code for the T4 template in this article's Github repo, but here's the gist of it; the now recursive ListPages method:
public void ListPages(string root, string path, string fileType, List<string> projectItems, int depth) { var info = new DirectoryInfo(path); var folder = info.Name; var pages = info.GetFiles($"*.{fileType}").Where(x => !x.Name.StartsWith("_") && projectItems.Any(y => x.FullName == y.Replace("/", "\\"))).ToList(); if (pages.Any() && root != path) { WriteLine($"{new string(' ', depth * 2)}public class {folder}"); WriteLine($"{new string(' ', depth * 2)}{{"); } foreach (var subFolder in Directory.GetDirectories(path)) { ListPages(root, subFolder, fileType, projectItems, depth + 1); } foreach (var page in pages) { var pageName = page.Name.Replace($".{fileType}", ""); var fileName = page.FullName.Substring(root.Length).Replace("\\", "/").Replace($".{fileType}", ""); WriteLine($"{new string(' ', (depth + 1) * 2)}public const string {pageName} = @\"{fileName}\";"); } if (pages.Any() && root != path) { WriteLine($"{new string(' ', depth * 2)}}}"); } }
I am sure this implementation can be improved further. For example it doesn't handle file names with unsupported characters (such as spaces or dashes). It's also set up to indent with two spaces (my usual default). But since it's plain code, directly in your project file, it's easy to modify it to suit your preferences or project requirements.
If you come up with any improvements, feel free to create a pull request in the Github repo for this article.
Happy templating, and thanks Kurt for the initial implementation years ago.
Source for this article
Where to Next?
Wonder where to go next? You can read existing comments below or you can post a comment yourself on this article.
Links in this Document
Doc ID | 615 |
Full URL | https://imar.spaanjaars.com/615/improving-intellisense-for-the-asp-page-tag-helper-using-t4-templates |
Short cut | https://imar.spaanjaars.com/615/ |
Written by | Imar Spaanjaars |
Date Posted | 11/11/2020 13:18 |
Talk Back! Comment on Imar.Spaanjaars.Com
I am interested in what you have to say about this article. Feel free to post any comments, remarks or questions you may have about this article. The Talk Back feature is not meant for technical questions that are not directly related to this article. So, a post like "Hey, can you tell me how I can upload files to a MySQL database in PHP?" is likely to be removed. Also spam and unrealistic job offers will be deleted immediately.
When you post a comment, you have to provide your name and the comment. Your e-mail address is optional and you only need to provide it if you want me to contact you. It will not be displayed along with your comment. I got sick and tired of the comment spam I was receiving, so I have protected this page with a simple calculation exercise. This means that if you want to leave a comment, you'll need to complete the calculation before you hit the Post Comment button.
If you want to object to a comment made by another visitor, be sure to contact me and I'll look into it ASAP. Don't forget to mention the page link, or the Doc ID of the document.