PodNet.Blazor.TypedRoutes 1.0.0-beta2

Prefix Reserved
This is a prerelease version of PodNet.Blazor.TypedRoutes.
There is a newer version of this package available.
See the version list below for details.
dotnet add package PodNet.Blazor.TypedRoutes --version 1.0.0-beta2                
NuGet\Install-Package PodNet.Blazor.TypedRoutes -Version 1.0.0-beta2                
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="PodNet.Blazor.TypedRoutes" Version="1.0.0-beta2">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>                
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add PodNet.Blazor.TypedRoutes --version 1.0.0-beta2                
#r "nuget: PodNet.Blazor.TypedRoutes, 1.0.0-beta2"                
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install PodNet.Blazor.TypedRoutes as a Cake Addin
#addin nuget:?package=PodNet.Blazor.TypedRoutes&version=1.0.0-beta2&prerelease

// Install PodNet.Blazor.TypedRoutes as a Cake Tool
#tool nuget:?package=PodNet.Blazor.TypedRoutes&version=1.0.0-beta2&prerelease                

PodNet.Blazor.TypedRoutes

An efficient and easy-to-use generator for strongly typed routes in Blazor.

Why Type Strongly?

I don't think there are many people that use Blazor that need convincing of the advantages of strong typing. Blazor, by default, doesn't apply strong typing to the routes defined in .razor files or component classes. You can define your component's route to be at /my-component, then pass that magic /my-component string around as if it was no one else's business! Then, just hope nobody changes the route of the component for whatever reason and breaks something seemingly unrelated. But, what if you could simply refer to the URL in a strongly typed manner, like <a href="@MyComponent.PageUri">My component</a>? Look no further, here's PodNet.Blazor.TypedRoutes to satiate all your strongly-typed component route needs!

Support and features

  • All Blazor hosting models (Blazor WebAssembly, Blazor Server, ASP.NET Core hosted Blazor WASM, hybrid with MAUI, etc.).
  • .NET 7+
  • dotnet CLI, VS Code or Visual Studio 2022+
  • Supports trimming for small file sizes, and adds no runtime dependencies whatsoever
  • Any number of @page directives and [Route] attributes for defining routes to be picked up, and even mix-and-match the two approaches if you want in your project, even in split code+markup components
  • Supports all Blazor route constraints (types), optional/nullable and catch-all parameters, multiple routes per component
  • The generated code is self-documenting with XML comments, so that you can see the discrete values they refer to when using IntelliSense
  • Extensible via generated interfaces: DIY sitemap, navigation, breadcrumbs, SEO etc.
  • A Roslyn incremental source generator for high performance code gen

Usage

  1. Install the NuGet package to your Blazor app (all hosting models are supported).
  2. Define your page components as usual. Either:
    1. use @page directives in .razor files, or
    2. use [Route] attributes in .cs files - in this case, the class has to be partial.
  3. For each found route, a static member will be automatically generated for the component you can use by referring to the type itself. You don't need to do anything special for the generated code to appear, it'll automatically get refreshed as you type as needed, or be built when you run dotnet build from the command line.

The logic for generating a primary PageUri property or method and additional methods (when there are multiple routes) is a bit opinionated, but it should "just work" for most scenarios without you having to configure or modify anything.

Example: parameterless routes

In an example project named MyExampleBlazorApp, using the well-known FetchData component from the default project template which defines @page "/fetchdata" as its route template, the generated code for the component is as follows:

// <auto-generated />
using System;
using System.Collections.Generic;
using PodNet.Blazor.TypedRoutes;

namespace MyExampleBlazorApp.Pages 
{
    partial class FetchData : IRoutableComponent, INavigableComponent
    {
        /// <summary>
        /// The primary route template for the component, the constant string <c>"/fetchdata"</c>.
        /// </summary>
        public static string PageRouteTemplate => "/fetchdata";

        /// <summary>
        /// All available route templates for the component, containing the string: <c>"/fetchdata</c>.
        /// </summary>
        public static IReadOnlyList<string> AllPageRouteTemplates { get; } = ImmutableArray.Create("/fetchdata");

        /// <summary>
        /// Returns the absolute page URI: <c>"/fetchdata"</c>.
        /// </summary>
        public static string PageUri => "/fetchdata";
    }
}

Usage of these members is very useful in any place you'd refer to the page's URI. Instead of using the page's URL as a hardcoded value, you could instead, for example, modify the Shared\NavMenu.razor file from:

<NavLink class="nav-link" href="fetchdata"> @* Welp, this isn't really typesafe here! *@
    <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink>

to instead refer to the page's URI directly by referring to it as you might expect:

<NavLink class="nav-link" href="@FetchData.PageUri"> @* MUCH better!! *@
    <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink>

You can also use the URI in any place you would pass it manually:

NavigationManager.NavigateTo(FetchData.PageUri);

Example: routes with parameters

Examples tell stories much better than words do.

Consider these routes:

@page "/items/{id}"
@page "/items/{category}/{id:int}"
@page "/items/{from:datetime}/{to:datetime?}"
@page "/other-pages/{**catchAll}"

The following members would be generated for each, for the component class:

/// <summary>
/// The primary route template for the component, the constant string <c>"/other-pages/{*catchAll}"</c>.
/// </summary>
public static string PageRouteTemplate => "/other-pages/{*catchAll}";

/// <summary>
/// All available route templates for the component, containing the strings: <c>"/items/{id}"</c>, <c>"/items/{category}/{id:int}"</c>, <c>"/items/{from:datetime}/{to:datetime?}"</c>, <c>"/other-pages/{*catchAll}"</c>.
/// </summary>
public static IReadOnlyList<string> AllPageRouteTemplates { get; } = ImmutableArray.Create("/items/{id}", "/items/{category}/{id:int}", "/items/{from:datetime}/{to:datetime?}", "/other-pages/{*catchAll}");

/// <summary>
/// Returns the absolute page URI: <c>"/other-pages"</c>.
/// </summary>
public static string PageUri => "/other-pages";

/// <summary>
/// Returns the URI for the page constructed from the template <c>"/items/{id}"</c> with
/// the provided parameters.
/// </summary>
public static string PageUri1(string id) => $"/items/{Uri.EscapeDataString(id)}";
        
/// <summary>
/// Returns the URI for the page constructed from the template <c>"/items/{category}/{id:int}"</c> with
/// the provided parameters.
/// </summary>
public static string PageUri2(string category, int id) => $"/items/{Uri.EscapeDataString(category)}/{id}";
        
/// <summary>
/// Returns the URI for the page constructed from the template <c>"/items/{from:datetime}/{to:datetime?}"</c> with
/// the provided parameters.
/// </summary>
public static string PageUri3(DateTime from, DateTime? to) => $"/items/{from.ToString(from.TimeOfDay == default ? "yyyy-MM-dd" : "s")}/{to?.ToString(to.Value.TimeOfDay == default ? "yyyy-MM-dd" : "s")}";
        
/// <summary>
/// Returns the URI for the page constructed from the template <c>"/other-pages/{*catchAll}"</c> with
/// the provided parameters.
/// </summary>
public static string PageUri4(string? catchAll = null) => $"/other-pages/{(catchAll == null ? null : Uri.EscapeDataString(catchAll))}";

Notice the following:

  • The "other-pages/{**catchAll}" route was "promoted" to be the primary route accessible via the PageUri property. That property is only available if there are any routes that have no or only optional (including catch-all) parameters. The property takes the first of these available, if any. So even though that route was the fourth, it became the "primary" route accessible via PageUri. This also made the primary PageRouteTemplate have the value of that route as well. Were there no parameterless routes available, there would be no PageUri property generated (only methods), and the PageRouteTemplate would take the value of the first available route.
  • If there is more than one route, there will be methods numbered from 1 through the number of routes available with the parameters for them with strongly typed parameters. The implementation is a bit opinionated, but essentially the default only "interpolates" the values into the URI.
  • The XML comments help you identify the route you wish to pick exactly in IntelliSense, by showing the route that is being constructed.

Additional examples

You can find additional examples in the TypedRoutes.Sample folder.

You can simply clone the repo and test your scenario by adding/modifying components and routes to this project. Keep in mind, however, that this differs in a few key points from installing via NuGet:

  • The project is set to write all source generated code to the Generated folder. It is a good idea to delete the folder before doing a build this way to avoid any leftover files. This is only so that it is convenient to see the result of the generation on GitHub. It is not advised to store generated content this way in source control in app dev projects.
  • To see the result of the generation, you have to explicitly execute Build on the project via <kbd>F6</kbd> or Build > Build > [Project] / Right click > Build in Visual Studio, via command line or other means. This is NOT needed when referencing the NuGet package.
  • To view the generated code for your project in Visual Studio, navigate in Solution Explorer to your project's Dependencies / Analyzers / PodNet.Blazor.TypedRoutes node, where all generated files should be visible.

Extensibility overview

You can take the routable component types and use strongly typed generics or typed components to take advantage of the power of C# and Razor. The generator always generates the following interfaces for your perusal:

// <auto-generated />
#nullable enable

using System;
using System.Collections.Generic;

namespace PodNet.Blazor.TypedRoutes
{
    public interface IRoutableComponent
    {
        /// <summary>
        /// Returns the page component's primary route template.
        /// </summary>
        public static abstract string PageRouteTemplate { get; }

        /// <summary>
        /// Returns all of the page component's route templates.
        /// </summary>
        public static abstract IReadOnlyList<string> AllPageRouteTemplates { get; }
    }

    public interface INavigableComponent
    {
        /// <summary>
        /// Returns the absolute page URI: <c>"{uri}"</c>. This is only possible if the page has a 
        /// primary route with no required parameters.
        /// </summary>
        public static abstract string PageUri { get; }
    }
}
#nullable restore

Your components will automatically implement these interfaces as you assign routes to your components. If your component has any routes, it'll implement IRoutableComponent, and if it has a parameterless route as well, it'll also implement INavigableComponent. You can use these abstractions as you want in user code. For example, you could write a component like so:

@* Nav.razor *@
@typeparam T where T : PodNet.Blazor.TypedRoutes.INavigableComponent

<a href="@T.PageUri">@typeof(T).Name</a>

Then, you can use this component simply by providing the other component type to show a link to it with its name and URI:

<Nav T="Index" />
<Nav T="Counter" />
<Nav T="FetchData" />

Which in turn renders the following:

<a href="/">Index</a>
<a href="/counter">Counter</a>
<a href="/fetchdata">FetchData</a>

Notice that this is so very strongly typed that it will only compile if there's at least one parameterless route available for any given T component type. I think it's quite neat.

You also could, theoretically, write something similar:

public static class MyNavigationManagerExtensions
{
    public static void NavigateTo<T>(this NavigationManager navigationManager, 
        bool forceReload = false, 
        bool replace = false) 
        where T : INavigableComponent
    {
        navigationManager.NavigateTo(T.PageUri, forceReload, replace);
    }
}

Then, you could use it like so:

navigationManager.NavigateTo<Index>();

However, as you can see, you could have already used the type itself to get the PageUri directly instead:

navigationManager.NavigateTo(Index.PageUri);

This is also the preferred method, because you can also use the generated methods as well (not just the property):

navigationManager.NavigateTo(Details.PageUri(id));

This is why no additional ways are provided by this package for routing, but if you have a suggestion, don't hesitate to tell us in the discussions.

Limitations

The .razor files are not syntactically or semantically analyzed in the same way the Razor Language Service does it. This mainly means that when defining a route in a .razor file, all lines of code that adhere to a specific regex pattern will be considered a match. Most limitations are derived from and are dependent on the [https://github.com/dotnet/roslyn/issues/57239](current limitation of the Roslyn source generators' architecture), where source generated code cannot depend on other source generated code.

  • If you comment out a @page directive with multiline comments, the route will still be generated for it if the directive is on its own line. You should consider removing the comment as a best practice for other reasons as well, for example as to not confuse build systems or code reviewers who only see line diffs in code comparison tools.
  • The @attribute [Route] directive to declare routes in .razor files is not supported. You can use any or all of the other approaches instead. If you wanted to define the templates in constants so that you can refer to them in [Route] attributes, it's worth considering to switch to the @page definition instead when using a .razor file, or just define the route in-place when using a class. This is because you'll get access to the page template constant values and the page URIs anyways when using this generator, so the only place you'll see the routes appear in your code is the declaration.

Additional notes and known limitations:

  • This is a small project and testing it properly end-to-end would require an enterprise-grade setup and effort to match. As such, you can try and use the package "as-is", but feel free to fork, contribute, help with features, report any bugs or just comment or request features. We hope you like it, and we do use it in production, but we take no responsibility or offer no guarantees for your usage of this package.
  • Lazy-loading support is untested. It works by default in most cases, but might not work in all cases out of the box. Specifically, lazy-loading assembly boundaries might result in the assembly containing the generated code be unavailable when referenced. You should try and avoid referencing code directly over lazy-loading assembly boundaries in general.
  • The current formatting logic takes into account the type of the parameter: strings are escaped via Uri.EscapeDataString (as to prevent XSS), and DateTimes either get formatted as yyyy-MM-dd or s (2008-06-15T21:15:07) depending on whether their TimeOfDay is set or not. Also, bool gets formatted as either true or false, as this is more common to see on the open web. All other types (decimal, double, float, guid, int, long) simply get interpolated into the URI without modification, and thus, might depend on the current culture formatting. Let us know if you need additional control over how the parameters are formatted in the constructed URI.
  • The generation works by generating static members for the component type itself. It would be possible (quite easy, in fact) to move all generated members instead into a singular generated class, or multiple classes that mirror the namespace structure of the original components by their namespaces. However, we consider it more staightforward and logically coherent to assign the routes to the component type itself - after all, the [Route] attribute is put onto the type metadata as well, and that is a natural place one would look for this kind of information. Also, if you can already "see" a component type in code because it's namespace is in scope, you can simply access its URI as well. However, if you would need a different structure for the generated code, let's discuss it.
  • Invalid routes (eg. multiple catch-all parameters, invalid constraints, malformed strings) are not handled and might or might not emit helper code.
  • The generated code declares a primary PageUri URI if there is at least one route found with no parameters (or only optional parameters). If there are multiple routes or the only route has required parameters, additional methods are generated for each of the routes. These follow the pattern PageUri{n}, where {n} is a natural number incrementing starting from 1. We couldn't figure out a straightforward way to name multiple methods accordingly unless they are numbered, and overloading wouldn't be work either as there could be collisions based on the parameters. The order of the generated memers are ordered, as follows: .razor files' @page directives first, in order they appear in source, then the partial component class' [Route] attributes, in order they appear in source. If there's only one route, then a property or method is declared named PageUri based on if it has parameters or not.
  • A convenient side effect of generating "onto" the existing class is that you can simply refer to the component's current routes by writing @PageUri in the current razor file, because the .razor file's context is the type's BuildRenderTree method, which can obviously access to surrounding type's static members. We advise however to consider prepending the component type's name when accessing static members, so as to avoid any ambiguity, like so: @FetchData.PageUri.
  • There is currently no way to opt-out or modify the behavior of the generator from the user's end. This package is intentionally opinionated. However, there might be some additions or modifications down the line when additional configuration would be needed. As always, don't hesitate to let your thoughts known in the discussions.

Shoutout

A well-deserved shoutout and thanks goes to KuraiAndras for BlazingRoute, which strongly inspired this package.

There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

  • .NETStandard 2.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
1.0.0 397 8/26/2024
1.0.0-beta6 149 1/23/2024
1.0.0-beta5 87 1/23/2024
1.0.0-beta4 140 1/3/2024
1.0.0-beta3 180 7/7/2023
1.0.0-beta2 148 6/16/2023
1.0.0-beta1 111 6/16/2023