The BrouterRoutes generator
Bit.Brouter.Generators is a Roslyn source generator that scans your
.razor files and emits BrouterRoutes - one compile-time-safe URL
builder per route. Rename a parameter or change a constraint and every stale caller becomes
a build error instead of a broken link.
What it scans
The generator reads route declarations textually from every .razor file:
@page directives, @attribute [Route(...)], and literal
<Broute Path="..."> tags - including nested trees, where a
child's template is composed onto its ancestors' prefixes. A dynamic path
(Path="@expr") can't be known at compile time, so that tag and everything
beneath it is skipped.
# Add the router and the generator to your Blazor project: dotnet add package Bit.Brouter dotnet add package Bit.Brouter.Generators <!-- ...or declare them in your .csproj. Bit.Brouter.Generators is an analyzer-only package (it ships no runtime assembly), so PrivateAssets="all" keeps it out of your own package's dependencies: --> <PackageReference Include="Bit.Brouter" Version="10.6.0-pre-03" /> <PackageReference Include="Bit.Brouter.Generators" Version="10.6.0-pre-03" PrivateAssets="all" />
Nothing else is wired up by hand: the package drops the generator into the compiler, and
the very next build emits BrouterRoutes from the routes already declared in
your project. No tool to run, no file to check in.
The output is a single static partial class BrouterRoutes in your project's
root namespace (BrouterRoutes.g.cs). Duplicate templates across files are
de-duplicated; declaring the same template with two different
Names raises the BRT001 warning instead of silently picking one.
From template to typed method
Route parameters become method parameters typed by the template's last type
constraint ({init:int} → int init; validation-only
constraints like min(1) keep the raw string). Optionals become optional
arguments, {action=Index} emits its declared default when omitted, and a
catch-all becomes an optional path string. Every method also takes an optional
query.
@* The declaration in AppRouter.razor... *@ <Broute Name="counter" Path="/counter/{init:int}"> // ...becomes this method in BrouterRoutes.g.cs: public static string Counter(int init, string? query = null) { ... } // Callers get IntelliSense, type checking, and refactoring safety: <BrouterLink Href="@BrouterRoutes.Counter(1234)">Counter</BrouterLink>
| Declared template | Generated signature |
|---|---|
| /counter/{init:int} | Counter(int init, string? query = null) |
| /profile/{username?} | ProfileByUsername(string? username = null, string? query = null) |
| /blog/{action=Index} | BlogByAction(string? action = null, string? query = null) |
| /posts/{**slug} | Posts(string? slug = null, string? query = null) |
Live output
These URLs are produced right now by the generated methods - note the emitted
default in /blog/Index, the per-segment escaping of the catch-all, and the
query appended with or without a leading ?.
| Expression | Result | |
|---|---|---|
| BrouterRoutes.Counter(42) | /counter/42 |
open → |
| BrouterRoutes.Counter(42, query: "step=5") | /counter/42?step=5 |
open → |
| BrouterRoutes.ProfileByUsername("saleh") | /profile/saleh |
open → |
| BrouterRoutes.ProfileByUsername() | /profile |
open → |
| BrouterRoutes.BlogByAction() | /blog/Index |
open → |
| BrouterRoutes.BlogByAction("archive") | /blog/archive |
open → |
| BrouterRoutes.Posts("2026/07/hello world") | /posts/2026/07/hello%20world |
open → |
Method naming & the Names class
An explicit Name="..." owns its method name (Name="counter" →
Counter(...), even if another template would derive the same identifier - the
other one gets a numeric suffix). Unnamed routes derive a name from their literal segments
plus a By{Param} suffix per parameter: /profile/{username?} →
ProfileByUsername. The bare root template becomes Root().
Named routes additionally get a constant in BrouterRoutes.Names, so
name-based APIs need no magic strings:
// Resolve or navigate by route name, without hardcoding "counter" anywhere: brouter.ResolveUrl(BrouterRoutes.Names.Counter, new Dictionary<string, object?> { ["init"] = 7 }); brouter.NavigateToName(BrouterRoutes.Names.Counter, new Dictionary<string, object?> { ["init"] = 777 });
Fidelity & edge cases
-
Values are formatted with the router's own invariant rules (
bool→true/false,DateTime→ round-trip"o") and escaped withUri.EscapeDataString, so a generated URL always round-trips through its template - byte-identical to whatResolveUrlproduces. -
Catch-all values are split on
/and escaped per segment:Posts("2026/07/hello world")→/posts/2026/07/hello%20world. -
A middle optional (
/products/{culture?}/list) is required at match time, so the generated method requires that argument too - runtime parity over template appearance. -
Complex segments (
/files/{name}.{ext?}) and dynamicPath="@expr"subtrees are skipped: no builder is generated rather than risk generating a wrong one.