├── SpawnDev.BlazorJS.IndexRouting
├── _Imports.razor
├── Assets
│ └── icon-128.png
├── Program.cs
├── Extensions
│ └── NavigationManagerExtensions.cs
├── Routing
│ ├── RouteTable.cs
│ ├── RouteTemplate.cs
│ ├── NavigationContext.cs
│ ├── RouteContext.cs
│ ├── RouteConstraint.cs
│ ├── StringSegmentAccumulator.cs
│ ├── RouteKey.cs
│ ├── TemplateParser.cs
│ ├── TemplateSegment.cs
│ ├── RouteEntry.cs
│ ├── UrlValueConstraint.cs
│ └── RouteTableFactory.cs
├── SpawnDev.BlazorJS.IndexRouting.csproj
└── Components
│ └── IndexRouter.cs
├── SpawnDev.BlazorJS.IndexRouting.Demo
├── wwwroot
│ ├── favicon.png
│ ├── icon-192.png
│ ├── sample-data
│ │ └── weather.json
│ ├── index.html
│ └── css
│ │ └── app.css
├── Pages
│ ├── Counter.razor
│ ├── UserProfile.razor
│ ├── Home.razor
│ └── Weather.razor
├── Layout
│ ├── MainLayout.razor
│ ├── MainLayout.razor.css
│ ├── NavMenu.razor.css
│ └── NavMenu.razor
├── _Imports.razor
├── Program.cs
├── App.razor
├── SpawnDev.BlazorJS.IndexRouting.Demo.csproj
└── Properties
│ └── launchSettings.json
├── LICENSE.txt
├── SpawnDev.BlazorJS.IndexRouting.sln
├── .github
└── workflows
│ └── main.yml
├── .gitattributes
├── README.md
└── .gitignore
/SpawnDev.BlazorJS.IndexRouting/_Imports.razor:
--------------------------------------------------------------------------------
1 | @using Microsoft.AspNetCore.Components.Web
2 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Assets/icon-128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LostBeard/SpawnDev.BlazorJS.IndexRouting/main/SpawnDev.BlazorJS.IndexRouting/Assets/icon-128.png
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/wwwroot/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LostBeard/SpawnDev.BlazorJS.IndexRouting/main/SpawnDev.BlazorJS.IndexRouting.Demo/wwwroot/favicon.png
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/wwwroot/icon-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LostBeard/SpawnDev.BlazorJS.IndexRouting/main/SpawnDev.BlazorJS.IndexRouting.Demo/wwwroot/icon-192.png
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Program.cs:
--------------------------------------------------------------------------------
1 | global using Microsoft.AspNetCore.Components;
2 | global using Microsoft.AspNetCore.Components.Routing;
3 | global using Microsoft.JSInterop;
4 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Extensions/NavigationManagerExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
2 |
3 | public static class NavigationManagerExtensions
4 | {
5 |
6 | }
7 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/Pages/Counter.razor:
--------------------------------------------------------------------------------
1 | @page "/counter"
2 |
3 | Counter
4 |
5 |
Counter
6 |
7 | Current count: @currentCount
8 |
9 | Click me
10 |
11 | @code {
12 | private int currentCount = 0;
13 |
14 | private void IncrementCount()
15 | {
16 | currentCount++;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/Layout/MainLayout.razor:
--------------------------------------------------------------------------------
1 | @inherits LayoutComponentBase
2 |
3 |
6 |
7 |
8 |
11 |
12 |
13 | @Body
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/Pages/UserProfile.razor:
--------------------------------------------------------------------------------
1 | @page "/user/{UserId:int}/profile"
2 |
3 | User Profile
4 |
5 | User Profile
6 |
7 | This is used to demonstrate an route with a query parameter.
8 |
9 | UserId: @UserId
10 | Style: @Style
11 |
12 | @code {
13 | [Parameter]
14 | public int UserId { get; set; }
15 |
16 | [SupplyParameterFromQuery]
17 | public string? Style { get; set; }
18 | }
19 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/Pages/Home.razor:
--------------------------------------------------------------------------------
1 | @page "/"
2 |
3 | Home
4 |
5 | SpawnDev.BlazorJS.IndexRouting
6 |
7 |
8 | SpawnDev.BlazorJS.IndexRouting contains IndexRouter, an alternative Route component for Blazor WebAssembly that routes all pages to `index.html` with the page route stored as a query parameter. This allows hosting methods that do not provide error page redirection, which can cause issues with client-side routing.
9 |
10 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/_Imports.razor:
--------------------------------------------------------------------------------
1 | @using System.Net.Http
2 | @using System.Net.Http.Json
3 | @using Microsoft.AspNetCore.Components.Forms
4 | @using Microsoft.AspNetCore.Components.Routing
5 | @using Microsoft.AspNetCore.Components.Web
6 | @using Microsoft.AspNetCore.Components.Web.Virtualization
7 | @using Microsoft.AspNetCore.Components.WebAssembly.Http
8 | @using Microsoft.JSInterop
9 | @using SpawnDev.BlazorJS.IndexRouting.Demo
10 | @using SpawnDev.BlazorJS.IndexRouting.Demo.Layout
11 |
12 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/Program.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Components.Web;
2 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
3 | using SpawnDev.BlazorJS.IndexRouting.Demo;
4 |
5 | var builder = WebAssemblyHostBuilder.CreateDefault(args);
6 | builder.RootComponents.Add("#app");
7 | builder.RootComponents.Add("head::after");
8 |
9 | builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
10 |
11 | await builder.Build().RunAsync();
12 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/wwwroot/sample-data/weather.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "date": "2022-01-06",
4 | "temperatureC": 1,
5 | "summary": "Freezing"
6 | },
7 | {
8 | "date": "2022-01-07",
9 | "temperatureC": 14,
10 | "summary": "Bracing"
11 | },
12 | {
13 | "date": "2022-01-08",
14 | "temperatureC": -13,
15 | "summary": "Freezing"
16 | },
17 | {
18 | "date": "2022-01-09",
19 | "temperatureC": -16,
20 | "summary": "Balmy"
21 | },
22 | {
23 | "date": "2022-01-10",
24 | "temperatureC": -2,
25 | "summary": "Chilly"
26 | }
27 | ]
28 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/App.razor:
--------------------------------------------------------------------------------
1 | @using SpawnDev.BlazorJS.IndexRouting.Routing
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Not found
10 |
11 | Sorry, there's nothing at this address.
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/RouteTable.cs:
--------------------------------------------------------------------------------
1 | // Licensed to the .NET Foundation under one or more agreements.
2 | // The .NET Foundation licenses this file to you under the MIT license.
3 |
4 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
5 |
6 | internal class RouteTable
7 | {
8 | public RouteTable(RouteEntry[] routes)
9 | {
10 | Routes = routes;
11 | }
12 |
13 | public RouteEntry[] Routes { get; }
14 |
15 | public void Route(RouteContext routeContext)
16 | {
17 | for (var i = 0; i < Routes.Length; i++)
18 | {
19 | Routes[i].Match(routeContext);
20 | if (routeContext.Handler != null)
21 | {
22 | return;
23 | }
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/SpawnDev.BlazorJS.IndexRouting.Demo.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 | false
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) [year] [fullname]
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/wwwroot/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | SpawnDev.BlazorJS.IndexRouting.Demo
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | An unhandled error has occurred.
26 |
Reload
27 |
🗙
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/RouteTemplate.cs:
--------------------------------------------------------------------------------
1 | // Licensed to the .NET Foundation under one or more agreements.
2 | // The .NET Foundation licenses this file to you under the MIT license.
3 |
4 | using System.Diagnostics;
5 |
6 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
7 |
8 | [DebuggerDisplay("{TemplateText}")]
9 | internal class RouteTemplate
10 | {
11 | public RouteTemplate(string templateText, TemplateSegment[] segments)
12 | {
13 | TemplateText = templateText;
14 | Segments = segments;
15 |
16 | for (var i = 0; i < segments.Length; i++)
17 | {
18 | var segment = segments[i];
19 | if (segment.IsOptional)
20 | {
21 | OptionalSegmentsCount++;
22 | }
23 | if (segment.IsCatchAll)
24 | {
25 | ContainsCatchAllSegment = true;
26 | }
27 | }
28 | }
29 |
30 | public string TemplateText { get; }
31 |
32 | public TemplateSegment[] Segments { get; }
33 |
34 | public int OptionalSegmentsCount { get; }
35 |
36 | public bool ContainsCatchAllSegment { get; }
37 | }
38 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/NavigationContext.cs:
--------------------------------------------------------------------------------
1 | // Licensed to the .NET Foundation under one or more agreements.
2 | // The .NET Foundation licenses this file to you under the MIT license.
3 |
4 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
5 |
6 | ///
7 | /// Provides information about the current asynchronous navigation event
8 | /// including the target path and the cancellation token.
9 | ///
10 | public sealed class NavigationContext
11 | {
12 | ///
13 | /// Initializes a new instance of .
14 | ///
15 | ///
16 | ///
17 | public NavigationContext(string path, CancellationToken cancellationToken)
18 | {
19 | Path = path;
20 | CancellationToken = cancellationToken;
21 | }
22 |
23 | ///
24 | /// The target path for the navigation.
25 | ///
26 | public string Path { get; }
27 |
28 | ///
29 | /// The to use to cancel navigation.
30 | ///
31 | public CancellationToken CancellationToken { get; }
32 | }
33 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/RouteContext.cs:
--------------------------------------------------------------------------------
1 | namespace SpawnDev.BlazorJS.IndexRouting.Routing
2 | {
3 | internal class RouteContext
4 | {
5 | // Licensed to the .NET Foundation under one or more agreements.
6 | // The .NET Foundation licenses this file to you under the MIT license.
7 |
8 | private static readonly char[] Separator = new[] { '/' };
9 |
10 | public RouteContext(string path)
11 | {
12 | // This is a simplification. We are assuming there are no paths like /a//b/. A proper routing
13 | // implementation would be more sophisticated.
14 | Segments = path.Trim('/').Split(Separator, StringSplitOptions.RemoveEmptyEntries);
15 | // Individual segments are URL-decoded in order to support arbitrary characters, assuming UTF-8 encoding.
16 | for (int i = 0; i < Segments.Length; i++)
17 | {
18 | Segments[i] = Uri.UnescapeDataString(Segments[i]);
19 | }
20 | }
21 |
22 | public string[] Segments { get; }
23 |
24 | public Type? Handler { get; set; }
25 |
26 | public IReadOnlyDictionary? Parameters { get; set; }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/RouteConstraint.cs:
--------------------------------------------------------------------------------
1 | // Licensed to the .NET Foundation under one or more agreements.
2 | // The .NET Foundation licenses this file to you under the MIT license.
3 |
4 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
5 |
6 | internal static class RouteConstraint
7 | {
8 | public static UrlValueConstraint Parse(string template, string segment, string constraint)
9 | {
10 | if (string.IsNullOrEmpty(constraint))
11 | {
12 | throw new ArgumentException($"Malformed segment '{segment}' in route '{template}' contains an empty constraint.");
13 | }
14 |
15 | var targetType = GetTargetType(constraint);
16 | if (targetType is null || !UrlValueConstraint.TryGetByTargetType(targetType, out var result))
17 | {
18 | throw new ArgumentException($"Unsupported constraint '{constraint}' in route '{template}'.");
19 | }
20 |
21 | return result;
22 | }
23 |
24 | private static Type? GetTargetType(string constraint) => constraint switch
25 | {
26 | "bool" => typeof(bool),
27 | "datetime" => typeof(DateTime),
28 | "decimal" => typeof(decimal),
29 | "double" => typeof(double),
30 | "float" => typeof(float),
31 | "guid" => typeof(Guid),
32 | "int" => typeof(int),
33 | "long" => typeof(long),
34 | _ => null,
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json.schemastore.org/launchsettings.json",
3 | "iisSettings": {
4 | "windowsAuthentication": false,
5 | "anonymousAuthentication": true,
6 | "iisExpress": {
7 | "applicationUrl": "http://localhost:47045",
8 | "sslPort": 44342
9 | }
10 | },
11 | "profiles": {
12 | "http": {
13 | "commandName": "Project",
14 | "dotnetRunMessages": true,
15 | "launchBrowser": true,
16 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
17 | "applicationUrl": "http://localhost:5279",
18 | "environmentVariables": {
19 | "ASPNETCORE_ENVIRONMENT": "Development"
20 | }
21 | },
22 | "https": {
23 | "commandName": "Project",
24 | "dotnetRunMessages": true,
25 | "launchBrowser": true,
26 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
27 | "applicationUrl": "https://localhost:7240;http://localhost:5279",
28 | "environmentVariables": {
29 | "ASPNETCORE_ENVIRONMENT": "Development"
30 | }
31 | },
32 | "IIS Express": {
33 | "commandName": "IISExpress",
34 | "launchBrowser": true,
35 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
36 | "environmentVariables": {
37 | "ASPNETCORE_ENVIRONMENT": "Development"
38 | }
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/Pages/Weather.razor:
--------------------------------------------------------------------------------
1 | @page "/weather"
2 | @inject HttpClient Http
3 |
4 | Weather
5 |
6 | Weather
7 |
8 | This component demonstrates fetching data from the server.
9 |
10 | @if (forecasts == null)
11 | {
12 | Loading...
13 | }
14 | else
15 | {
16 |
17 |
18 |
19 | Date
20 | Temp. (C)
21 | Temp. (F)
22 | Summary
23 |
24 |
25 |
26 | @foreach (var forecast in forecasts)
27 | {
28 |
29 | @forecast.Date.ToShortDateString()
30 | @forecast.TemperatureC
31 | @forecast.TemperatureF
32 | @forecast.Summary
33 |
34 | }
35 |
36 |
37 | }
38 |
39 | @code {
40 | private WeatherForecast[]? forecasts;
41 |
42 | protected override async Task OnInitializedAsync()
43 | {
44 | forecasts = await Http.GetFromJsonAsync("sample-data/weather.json");
45 | }
46 |
47 | public class WeatherForecast
48 | {
49 | public DateOnly Date { get; set; }
50 |
51 | public int TemperatureC { get; set; }
52 |
53 | public string? Summary { get; set; }
54 |
55 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/Layout/MainLayout.razor.css:
--------------------------------------------------------------------------------
1 | .page {
2 | position: relative;
3 | display: flex;
4 | flex-direction: column;
5 | }
6 |
7 | main {
8 | flex: 1;
9 | }
10 |
11 | .sidebar {
12 | background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
13 | }
14 |
15 | .top-row {
16 | background-color: #f7f7f7;
17 | border-bottom: 1px solid #d6d5d5;
18 | justify-content: flex-end;
19 | height: 3.5rem;
20 | display: flex;
21 | align-items: center;
22 | }
23 |
24 | .top-row ::deep a, .top-row ::deep .btn-link {
25 | white-space: nowrap;
26 | margin-left: 1.5rem;
27 | text-decoration: none;
28 | }
29 |
30 | .top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
31 | text-decoration: underline;
32 | }
33 |
34 | .top-row ::deep a:first-child {
35 | overflow: hidden;
36 | text-overflow: ellipsis;
37 | }
38 |
39 | @media (max-width: 640.98px) {
40 | .top-row {
41 | justify-content: space-between;
42 | }
43 |
44 | .top-row ::deep a, .top-row ::deep .btn-link {
45 | margin-left: 0;
46 | }
47 | }
48 |
49 | @media (min-width: 641px) {
50 | .page {
51 | flex-direction: row;
52 | }
53 |
54 | .sidebar {
55 | width: 250px;
56 | height: 100vh;
57 | position: sticky;
58 | top: 0;
59 | }
60 |
61 | .top-row {
62 | position: sticky;
63 | top: 0;
64 | z-index: 1;
65 | }
66 |
67 | .top-row.auth ::deep a:first-child {
68 | flex: 1;
69 | text-align: right;
70 | width: 0;
71 | }
72 |
73 | .top-row, article {
74 | padding-left: 2rem !important;
75 | padding-right: 1.5rem !important;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.13.35723.152
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpawnDev.BlazorJS.IndexRouting", "SpawnDev.BlazorJS.IndexRouting\SpawnDev.BlazorJS.IndexRouting.csproj", "{8CC4F852-4D8E-22BB-060F-3FFBC82006A9}"
7 | EndProject
8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpawnDev.BlazorJS.IndexRouting.Demo", "SpawnDev.BlazorJS.IndexRouting.Demo\SpawnDev.BlazorJS.IndexRouting.Demo.csproj", "{08BC9977-4088-43F9-BB30-54415D759441}"
9 | EndProject
10 | Global
11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
12 | Debug|Any CPU = Debug|Any CPU
13 | Release|Any CPU = Release|Any CPU
14 | EndGlobalSection
15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
16 | {8CC4F852-4D8E-22BB-060F-3FFBC82006A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
17 | {8CC4F852-4D8E-22BB-060F-3FFBC82006A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
18 | {8CC4F852-4D8E-22BB-060F-3FFBC82006A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
19 | {8CC4F852-4D8E-22BB-060F-3FFBC82006A9}.Release|Any CPU.Build.0 = Release|Any CPU
20 | {08BC9977-4088-43F9-BB30-54415D759441}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
21 | {08BC9977-4088-43F9-BB30-54415D759441}.Debug|Any CPU.Build.0 = Debug|Any CPU
22 | {08BC9977-4088-43F9-BB30-54415D759441}.Release|Any CPU.ActiveCfg = Release|Any CPU
23 | {08BC9977-4088-43F9-BB30-54415D759441}.Release|Any CPU.Build.0 = Release|Any CPU
24 | EndGlobalSection
25 | GlobalSection(SolutionProperties) = preSolution
26 | HideSolutionNode = FALSE
27 | EndGlobalSection
28 | GlobalSection(ExtensibilityGlobals) = postSolution
29 | SolutionGuid = {18E49985-1069-41E6-95CF-B5C244D0D8EB}
30 | EndGlobalSection
31 | EndGlobal
32 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/StringSegmentAccumulator.cs:
--------------------------------------------------------------------------------
1 | // Licensed to the .NET Foundation under one or more agreements.
2 | // The .NET Foundation licenses this file to you under the MIT license.
3 |
4 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
5 |
6 | // This is very similar to Microsoft.Extensions.Primitives.StringValues, except it works in terms
7 | // of ReadOnlyMemory rather than string, so the querystring handling logic doesn't need to
8 | // allocate per-value when tracking things that will be parsed as value types.
9 | internal struct StringSegmentAccumulator
10 | {
11 | private int count;
12 | private ReadOnlyMemory _single;
13 | private List>? _multiple;
14 |
15 | public ReadOnlyMemory this[int index]
16 | {
17 | get
18 | {
19 | if (index >= count)
20 | {
21 | throw new IndexOutOfRangeException();
22 | }
23 |
24 | return count == 1 ? _single : _multiple![index];
25 | }
26 | }
27 |
28 | public int Count => count;
29 |
30 | public void SetSingle(ReadOnlyMemory value)
31 | {
32 | _single = value;
33 |
34 | if (count != 1)
35 | {
36 | if (count > 1)
37 | {
38 | _multiple = null;
39 | }
40 |
41 | count = 1;
42 | }
43 | }
44 |
45 | public void Add(ReadOnlyMemory value)
46 | {
47 | switch (count++)
48 | {
49 | case 0:
50 | _single = value;
51 | break;
52 | case 1:
53 | _multiple = new();
54 | _multiple.Add(_single);
55 | _multiple.Add(value);
56 | _single = default;
57 | break;
58 | default:
59 | _multiple!.Add(value);
60 | break;
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | # Run workflow on every push to the master branch
4 | on: workflow_dispatch
5 |
6 | jobs:
7 | deploy-to-github-pages:
8 | permissions:
9 | contents: write
10 | # use ubuntu-latest image to run steps on
11 | runs-on: ubuntu-latest
12 | steps:
13 | # uses GitHub's checkout action to checkout code form the master branch
14 | - uses: actions/checkout@v2
15 |
16 | # sets up .NET Core SDK
17 | - name: Setup .NET Core SDK
18 | uses: actions/setup-dotnet@v3.0.3
19 | with:
20 | dotnet-version: 9
21 |
22 | # Install dotnet wasm buildtools workload
23 | - name: Install .NET WASM Build Tools
24 | run: dotnet workload install wasm-tools-net8
25 |
26 | # publishes Blazor project to the publish-folder
27 | - name: Publish .NET Core Project
28 | run: dotnet publish ./SpawnDev.BlazorJS.IndexRouting.Demo/ --nologo -c:Release --output publish
29 |
30 | # changes the base-tag in index.html from '/' to '/SpawnDev.BlazorJS.IndexRouting/' to match GitHub Pages repository subdirectory
31 | - name: Change base-tag in index.html from / to /SpawnDev.BlazorJS.IndexRouting/
32 | run: sed -i 's/
9 | {
10 | public readonly Assembly? AppAssembly;
11 | public readonly HashSet? AdditionalAssemblies;
12 |
13 | public RouteKey(Assembly appAssembly, IEnumerable additionalAssemblies)
14 | {
15 | AppAssembly = appAssembly;
16 | AdditionalAssemblies = additionalAssemblies is null ? null : new HashSet(additionalAssemblies);
17 | }
18 |
19 | public override bool Equals(object? obj)
20 | {
21 | return obj is RouteKey other && Equals(other);
22 | }
23 |
24 | public bool Equals(RouteKey other)
25 | {
26 | if (!Equals(AppAssembly, other.AppAssembly))
27 | {
28 | return false;
29 | }
30 |
31 | if (AdditionalAssemblies is null && other.AdditionalAssemblies is null)
32 | {
33 | return true;
34 | }
35 |
36 | if (AdditionalAssemblies is null || other.AdditionalAssemblies is null)
37 | {
38 | return false;
39 | }
40 |
41 | return AdditionalAssemblies.Count == other.AdditionalAssemblies.Count &&
42 | AdditionalAssemblies.SetEquals(other.AdditionalAssemblies);
43 | }
44 |
45 | public override int GetHashCode()
46 | {
47 | if (AppAssembly is null)
48 | {
49 | return 0;
50 | }
51 |
52 | if (AdditionalAssemblies is null)
53 | {
54 | return AppAssembly.GetHashCode();
55 | }
56 |
57 | // Producing a hash code that includes individual assemblies requires it to have a stable order.
58 | // We'll avoid the cost of sorting and simply include the number of assemblies instead.
59 | return HashCode.Combine(AppAssembly, AdditionalAssemblies.Count);
60 | }
61 | }
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/SpawnDev.BlazorJS.IndexRouting.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0;net7.0;net8.0;net9.0
5 | enable
6 | enable
7 | 1.0.0
8 | True
9 | true
10 | true
11 | Embedded
12 | SpawnDev.BlazorJS.IndexRouting
13 | LostBeard
14 |
15 | Contains IndexRouter, an alternative Route component for Blazor WebAssembly that routes all pages to index.html with the page route stored as a query parameter.
16 |
17 | https://github.com/LostBeard/SpawnDev.BlazorJS.IndexRouting
18 | README.md
19 | LICENSE.txt
20 | icon-128.png
21 | https://github.com/LostBeard/SpawnDev.BlazorJS.IndexRouting.git
22 | git
23 | Blazor;BlazorWebAssembly;Routing;
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | ###############################################################################
2 | # Set default behavior to automatically normalize line endings.
3 | ###############################################################################
4 | * text=auto
5 |
6 | ###############################################################################
7 | # Set default behavior for command prompt diff.
8 | #
9 | # This is need for earlier builds of msysgit that does not have it on by
10 | # default for csharp files.
11 | # Note: This is only used by command line
12 | ###############################################################################
13 | #*.cs diff=csharp
14 |
15 | ###############################################################################
16 | # Set the merge driver for project and solution files
17 | #
18 | # Merging from the command prompt will add diff markers to the files if there
19 | # are conflicts (Merging from VS is not affected by the settings below, in VS
20 | # the diff markers are never inserted). Diff markers may cause the following
21 | # file extensions to fail to load in VS. An alternative would be to treat
22 | # these files as binary and thus will always conflict and require user
23 | # intervention with every merge. To do so, just uncomment the entries below
24 | ###############################################################################
25 | #*.sln merge=binary
26 | #*.csproj merge=binary
27 | #*.vbproj merge=binary
28 | #*.vcxproj merge=binary
29 | #*.vcproj merge=binary
30 | #*.dbproj merge=binary
31 | #*.fsproj merge=binary
32 | #*.lsproj merge=binary
33 | #*.wixproj merge=binary
34 | #*.modelproj merge=binary
35 | #*.sqlproj merge=binary
36 | #*.wwaproj merge=binary
37 |
38 | ###############################################################################
39 | # behavior for image files
40 | #
41 | # image files are treated as binary by default.
42 | ###############################################################################
43 | #*.jpg binary
44 | #*.png binary
45 | #*.gif binary
46 |
47 | ###############################################################################
48 | # diff behavior for common document formats
49 | #
50 | # Convert binary document formats to text before diffing them. This feature
51 | # is only available from the command line. Turn it on by uncommenting the
52 | # entries below.
53 | ###############################################################################
54 | #*.doc diff=astextplain
55 | #*.DOC diff=astextplain
56 | #*.docx diff=astextplain
57 | #*.DOCX diff=astextplain
58 | #*.dot diff=astextplain
59 | #*.DOT diff=astextplain
60 | #*.pdf diff=astextplain
61 | #*.PDF diff=astextplain
62 | #*.rtf diff=astextplain
63 | #*.RTF diff=astextplain
64 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/Layout/NavMenu.razor.css:
--------------------------------------------------------------------------------
1 | .navbar-toggler {
2 | background-color: rgba(255, 255, 255, 0.1);
3 | }
4 |
5 | .top-row {
6 | height: 3.5rem;
7 | background-color: rgba(0,0,0,0.4);
8 | }
9 |
10 | .navbar-brand {
11 | font-size: 1.1rem;
12 | }
13 |
14 | .bi {
15 | display: inline-block;
16 | position: relative;
17 | width: 1.25rem;
18 | height: 1.25rem;
19 | margin-right: 0.75rem;
20 | top: -1px;
21 | background-size: cover;
22 | }
23 |
24 | .bi-house-door-fill-nav-menu {
25 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
26 | }
27 |
28 | .bi-plus-square-fill-nav-menu {
29 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
30 | }
31 |
32 | .bi-list-nested-nav-menu {
33 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
34 | }
35 |
36 | .nav-item {
37 | font-size: 0.9rem;
38 | padding-bottom: 0.5rem;
39 | }
40 |
41 | .nav-item:first-of-type {
42 | padding-top: 1rem;
43 | }
44 |
45 | .nav-item:last-of-type {
46 | padding-bottom: 1rem;
47 | }
48 |
49 | .nav-item ::deep a {
50 | color: #d7d7d7;
51 | border-radius: 4px;
52 | height: 3rem;
53 | display: flex;
54 | align-items: center;
55 | line-height: 3rem;
56 | }
57 |
58 | .nav-item ::deep a.active {
59 | background-color: rgba(255,255,255,0.37);
60 | color: white;
61 | }
62 |
63 | .nav-item ::deep a:hover {
64 | background-color: rgba(255,255,255,0.1);
65 | color: white;
66 | }
67 |
68 | @media (min-width: 641px) {
69 | .navbar-toggler {
70 | display: none;
71 | }
72 |
73 | .collapse {
74 | /* Never collapse the sidebar for wide screens */
75 | display: block;
76 | }
77 |
78 | .nav-scrollable {
79 | /* Allow sidebar to scroll for tall menus */
80 | height: calc(100vh - 3.5rem);
81 | overflow-y: auto;
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/Layout/NavMenu.razor:
--------------------------------------------------------------------------------
1 |
9 |
10 |
43 |
44 | @code {
45 | private bool collapseNavMenu = true;
46 |
47 | private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
48 |
49 | private void ToggleNavMenu()
50 | {
51 | collapseNavMenu = !collapseNavMenu;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SpawnDev.BlazorJS.IndexRouting
2 |
3 | [](https://www.nuget.org/packages/SpawnDev.BlazorJS.IndexRouting)
4 |
5 | Contains IndexRouter, an alternative Route component for Blazor WebAssembly that routes all pages to `index.html` with the page route stored as a query parameter. This allows hosting methods that do not provide error page redirection, which can cause issues with client-side routing.
6 |
7 | Default `` rendered url:
8 | `https://www.mysite.com/user/42/profile?style=dark`
9 |
10 | `` rendered url:
11 | `https://www.mysite.com/index.html?$=user/42/profile&style=dark`
12 |
13 | IComponents can use the `@page` attribute and parameters normally.
14 |
15 | ### Demo
16 | [Simple Demo](https://lostbeard.github.io/SpawnDev.BlazorJS.IndexRouting/)
17 |
18 | ### Getting started
19 | Add the Nuget package `SpawnDev.BlazorJS.IndexRouting` to your project using your package manager of choice.
20 |
21 | #### Use IndexRouter
22 | Replace `` with `` in `App.razor`
23 | ```razor
24 | @using SpawnDev.BlazorJS.IndexRouting.Routing
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Not found
33 |
34 | Sorry, there's nothing at this address.
35 |
36 |
37 |
38 | ```
39 |
40 | #### Update NavLinks
41 | Change `` `href` values to use `index.html?$=[PAGE_ROUTE]` format in `NavMenu.razor`
42 | ```razor
43 |
67 | ```
68 |
69 | While `NavLinks` will still work without updating the `href` values, the 'active link' indicator they provide will not work correctly.
70 |
71 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting.Demo/wwwroot/css/app.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
3 | }
4 |
5 | h1:focus {
6 | outline: none;
7 | }
8 |
9 | a, .btn-link {
10 | color: #0071c1;
11 | }
12 |
13 | .btn-primary {
14 | color: #fff;
15 | background-color: #1b6ec2;
16 | border-color: #1861ac;
17 | }
18 |
19 | .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
20 | box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
21 | }
22 |
23 | .content {
24 | padding-top: 1.1rem;
25 | }
26 |
27 | .valid.modified:not([type=checkbox]) {
28 | outline: 1px solid #26b050;
29 | }
30 |
31 | .invalid {
32 | outline: 1px solid red;
33 | }
34 |
35 | .validation-message {
36 | color: red;
37 | }
38 |
39 | #blazor-error-ui {
40 | background: lightyellow;
41 | bottom: 0;
42 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
43 | display: none;
44 | left: 0;
45 | padding: 0.6rem 1.25rem 0.7rem 1.25rem;
46 | position: fixed;
47 | width: 100%;
48 | z-index: 1000;
49 | }
50 |
51 | #blazor-error-ui .dismiss {
52 | cursor: pointer;
53 | position: absolute;
54 | right: 0.75rem;
55 | top: 0.5rem;
56 | }
57 |
58 | .blazor-error-boundary {
59 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
60 | padding: 1rem 1rem 1rem 3.7rem;
61 | color: white;
62 | }
63 |
64 | .blazor-error-boundary::after {
65 | content: "An error has occurred."
66 | }
67 |
68 | .loading-progress {
69 | position: relative;
70 | display: block;
71 | width: 8rem;
72 | height: 8rem;
73 | margin: 20vh auto 1rem auto;
74 | }
75 |
76 | .loading-progress circle {
77 | fill: none;
78 | stroke: #e0e0e0;
79 | stroke-width: 0.6rem;
80 | transform-origin: 50% 50%;
81 | transform: rotate(-90deg);
82 | }
83 |
84 | .loading-progress circle:last-child {
85 | stroke: #1b6ec2;
86 | stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
87 | transition: stroke-dasharray 0.05s ease-in-out;
88 | }
89 |
90 | .loading-progress-text {
91 | position: absolute;
92 | text-align: center;
93 | font-weight: bold;
94 | inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
95 | }
96 |
97 | .loading-progress-text:after {
98 | content: var(--blazor-load-percentage-text, "Loading");
99 | }
100 |
101 | code {
102 | color: #c02d76;
103 | }
104 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/TemplateParser.cs:
--------------------------------------------------------------------------------
1 | // Licensed to the .NET Foundation under one or more agreements.
2 | // The .NET Foundation licenses this file to you under the MIT license.
3 | using System;
4 |
5 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
6 |
7 | // This implementation is temporary, in the future we'll want to have
8 | // a more performant/properly designed routing set of abstractions.
9 | // To be more precise these are some things we are scoping out:
10 | // * We are not doing link generation.
11 | // * We are not supporting all the route constraint formats supported by ASP.NET server-side routing.
12 | // The class in here just takes care of parsing a route and extracting
13 | // simple parameters from it.
14 | // Some differences with ASP.NET Core routes are:
15 | // * We don't support complex segments.
16 | // The things that we support are:
17 | // * Literal path segments. (Like /Path/To/Some/Page)
18 | // * Parameter path segments (Like /Customer/{Id}/Orders/{OrderId})
19 | // * Catch-all parameters (Like /blog/{*slug})
20 | internal class TemplateParser
21 | {
22 | public static readonly char[] InvalidParameterNameCharacters =
23 | new char[] { '{', '}', '=', '.' };
24 |
25 | internal static RouteTemplate ParseTemplate(string template)
26 | {
27 | var originalTemplate = template;
28 | template = template.Trim('/');
29 | if (template == string.Empty)
30 | {
31 | // Special case "/";
32 | return new RouteTemplate("/", Array.Empty());
33 | }
34 |
35 | var segments = template.Split('/');
36 | var templateSegments = new TemplateSegment[segments.Length];
37 | for (int i = 0; i < segments.Length; i++)
38 | {
39 | var segment = segments[i];
40 | if (string.IsNullOrEmpty(segment))
41 | {
42 | throw new InvalidOperationException(
43 | $"Invalid template '{template}'. Empty segments are not allowed.");
44 | }
45 |
46 | if (segment[0] != '{')
47 | {
48 | if (segment[segment.Length - 1] == '}')
49 | {
50 | throw new InvalidOperationException(
51 | $"Invalid template '{template}'. Missing '{{' in parameter segment '{segment}'.");
52 | }
53 | if (segment[^1] == '?')
54 | {
55 | throw new InvalidOperationException(
56 | $"Invalid template '{template}'. '?' is not allowed in literal segment '{segment}'.");
57 | }
58 | templateSegments[i] = new TemplateSegment(originalTemplate, segment, isParameter: false);
59 | }
60 | else
61 | {
62 | if (segment[segment.Length - 1] != '}')
63 | {
64 | throw new InvalidOperationException(
65 | $"Invalid template '{template}'. Missing '}}' in parameter segment '{segment}'.");
66 | }
67 |
68 | if (segment.Length < 3)
69 | {
70 | throw new InvalidOperationException(
71 | $"Invalid template '{template}'. Empty parameter name in segment '{segment}' is not allowed.");
72 | }
73 |
74 | var invalidCharacter = segment.IndexOfAny(InvalidParameterNameCharacters, 1, segment.Length - 2);
75 | if (invalidCharacter != -1)
76 | {
77 | throw new InvalidOperationException(
78 | $"Invalid template '{template}'. The character '{segment[invalidCharacter]}' in parameter segment '{segment}' is not allowed.");
79 | }
80 |
81 | templateSegments[i] = new TemplateSegment(originalTemplate, segment.Substring(1, segment.Length - 2), isParameter: true);
82 | }
83 | }
84 |
85 | for (int i = 0; i < templateSegments.Length; i++)
86 | {
87 | var currentSegment = templateSegments[i];
88 |
89 | if (currentSegment.IsCatchAll && i != templateSegments.Length - 1)
90 | {
91 | throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter can only appear as the last segment of the route template.");
92 | }
93 |
94 | if (!currentSegment.IsParameter)
95 | {
96 | continue;
97 | }
98 |
99 | for (int j = i + 1; j < templateSegments.Length; j++)
100 | {
101 | var nextSegment = templateSegments[j];
102 |
103 | if (currentSegment.IsOptional && !nextSegment.IsOptional && !nextSegment.IsCatchAll)
104 | {
105 | throw new InvalidOperationException($"Invalid template '{template}'. Non-optional parameters or literal routes cannot appear after optional parameters.");
106 | }
107 |
108 | if (string.Equals(currentSegment.Value, nextSegment.Value, StringComparison.OrdinalIgnoreCase))
109 | {
110 | throw new InvalidOperationException(
111 | $"Invalid template '{template}'. The parameter '{currentSegment}' appears multiple times.");
112 | }
113 | }
114 | }
115 |
116 | return new RouteTemplate(template, templateSegments);
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/TemplateSegment.cs:
--------------------------------------------------------------------------------
1 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
2 |
3 | internal class TemplateSegment
4 | {
5 | public TemplateSegment(string template, string segment, bool isParameter)
6 | {
7 | IsParameter = isParameter;
8 |
9 | IsCatchAll = isParameter && segment.StartsWith('*');
10 |
11 | if (IsCatchAll)
12 | {
13 | // Only one '*' currently allowed
14 | Value = segment[1..];
15 |
16 | var invalidCharacterIndex = Value.IndexOf('*');
17 | if (invalidCharacterIndex != -1)
18 | {
19 | throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter may only have one '*' at the beginning of the segment.");
20 | }
21 | }
22 | else
23 | {
24 | Value = segment;
25 | }
26 |
27 | // Process segments that parameters that do not contain a token separating a type constraint.
28 | if (IsParameter)
29 | {
30 | if (Value.IndexOf(':') < 0)
31 | {
32 |
33 | // Set the IsOptional flag to true for segments that contain
34 | // a parameter with no type constraints but optionality set
35 | // via the '?' token.
36 | var questionMarkIndex = Value.IndexOf('?');
37 | if (questionMarkIndex == Value.Length - 1)
38 | {
39 | IsOptional = true;
40 | Value = Value[0..^1];
41 | }
42 | // If the `?` optional marker shows up in the segment but not at the very end,
43 | // then throw an error.
44 | else if (questionMarkIndex >= 0)
45 | {
46 | throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}'. '?' character can only appear at the end of parameter name.");
47 | }
48 |
49 | Constraints = Array.Empty();
50 | }
51 | else
52 | {
53 | var tokens = Value.Split(':');
54 | if (tokens[0].Length == 0)
55 | {
56 | throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list.");
57 | }
58 |
59 | Value = tokens[0];
60 | IsOptional = tokens[^1].EndsWith('?');
61 | if (IsOptional)
62 | {
63 | tokens[^1] = tokens[^1][0..^1];
64 | }
65 |
66 | Constraints = new UrlValueConstraint[tokens.Length - 1];
67 | for (var i = 1; i < tokens.Length; i++)
68 | {
69 | Constraints[i - 1] = RouteConstraint.Parse(template, segment, tokens[i]);
70 | }
71 | }
72 | }
73 | else
74 | {
75 | Constraints = Array.Empty();
76 | }
77 |
78 | if (IsParameter)
79 | {
80 | if (IsOptional && IsCatchAll)
81 | {
82 | throw new InvalidOperationException($"Invalid segment '{segment}' in route '{template}'. A catch-all parameter cannot be marked optional.");
83 | }
84 |
85 | // Moving the check for this here instead of TemplateParser so we can allow catch-all.
86 | // We checked for '*' up above specifically for catch-all segments, this one checks for all others
87 | if (Value.IndexOf('*') != -1)
88 | {
89 | throw new InvalidOperationException($"Invalid template '{template}'. The character '*' in parameter segment '{{{segment}}}' is not allowed.");
90 | }
91 | }
92 | }
93 |
94 | // The value of the segment. The exact text to match when is a literal.
95 | // The parameter name when its a segment
96 | public string Value { get; }
97 |
98 | public bool IsParameter { get; }
99 |
100 | public bool IsOptional { get; }
101 |
102 | public bool IsCatchAll { get; }
103 |
104 | public UrlValueConstraint[] Constraints { get; }
105 |
106 | public bool Match(string pathSegment, out object? matchedParameterValue)
107 | {
108 | if (IsParameter)
109 | {
110 | matchedParameterValue = pathSegment;
111 |
112 | foreach (var constraint in Constraints)
113 | {
114 | if (!constraint.TryParse(pathSegment, out matchedParameterValue))
115 | {
116 | return false;
117 | }
118 | }
119 |
120 | return true;
121 | }
122 | else
123 | {
124 | matchedParameterValue = null;
125 | return string.Equals(Value, pathSegment, StringComparison.OrdinalIgnoreCase);
126 | }
127 | }
128 |
129 | public override string ToString() => this switch
130 | {
131 | { IsParameter: true, IsOptional: false, IsCatchAll: false, Constraints: { Length: 0 } } => $"{{{Value}}}",
132 | { IsParameter: true, IsOptional: false, IsCatchAll: false, Constraints: { Length: > 0 } } => $"{{{Value}:{string.Join(':', (object[])Constraints)}}}",
133 | { IsParameter: true, IsOptional: true, Constraints: { Length: 0 } } => $"{{{Value}?}}",
134 | { IsParameter: true, IsOptional: true, Constraints: { Length: > 0 } } => $"{{{Value}:{string.Join(':', (object[])Constraints)}?}}",
135 | { IsParameter: true, IsCatchAll: true, Constraints: { Length: 0 } } => $"{{*{Value}}}",
136 | { IsParameter: true, IsCatchAll: true, Constraints: { Length: > 0 } } => $"{{*{Value}:{string.Join(':', (object[])Constraints)}?}}",
137 | { IsParameter: false } => Value,
138 | _ => throw new InvalidOperationException("Invalid template segment.")
139 | };
140 | }
141 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/RouteEntry.cs:
--------------------------------------------------------------------------------
1 | // Licensed to the .NET Foundation under one or more agreements.
2 | // The .NET Foundation licenses this file to you under the MIT license.
3 |
4 | #nullable disable warnings
5 |
6 | using System.Diagnostics;
7 |
8 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
9 |
10 | [DebuggerDisplay("Handler = {Handler}, Template = {Template}")]
11 | internal class RouteEntry
12 | {
13 | public RouteEntry(RouteTemplate template, Type handler, List? unusedRouteParameterNames)
14 | {
15 | Template = template;
16 | UnusedRouteParameterNames = unusedRouteParameterNames;
17 | Handler = handler;
18 | }
19 |
20 | public RouteTemplate Template { get; }
21 |
22 | public List? UnusedRouteParameterNames { get; }
23 |
24 | public Type Handler { get; }
25 |
26 | internal void Match(RouteContext context)
27 | {
28 | var pathIndex = 0;
29 | var templateIndex = 0;
30 | Dictionary parameters = null;
31 | // We will iterate over the path segments and the template segments until we have consumed
32 | // one of them.
33 | // There are three cases we need to account here for:
34 | // * Path is shorter than template ->
35 | // * This can match only if we have t-p optional parameters at the end.
36 | // * Path and template have the same number of segments
37 | // * This can happen when the catch-all segment matches 1 segment
38 | // * This can happen when an optional parameter has been specified.
39 | // * This can happen when the route only contains literals and parameters.
40 | // * Path is longer than template -> This can only match if the parameter has a catch-all at the end.
41 | // * We still need to iterate over all the path segments if the catch-all is constrained.
42 | // * We still need to iterate over all the template/path segments before the catch-all
43 | while (pathIndex < context.Segments.Length && templateIndex < Template.Segments.Length)
44 | {
45 | var pathSegment = context.Segments[pathIndex];
46 | var templateSegment = Template.Segments[templateIndex];
47 |
48 | var matches = templateSegment.Match(pathSegment, out var match);
49 | if (!matches)
50 | {
51 | // A constraint or literal didn't match
52 | return;
53 | }
54 |
55 | if (!templateSegment.IsCatchAll)
56 | {
57 | // We were dealing with a literal or a parameter, so just advance both cursors.
58 | pathIndex++;
59 | templateIndex++;
60 |
61 | if (templateSegment.IsParameter)
62 | {
63 | parameters ??= new(StringComparer.OrdinalIgnoreCase);
64 | parameters[templateSegment.Value] = match;
65 | }
66 | }
67 | else
68 | {
69 | if (templateSegment.Constraints.Length == 0)
70 | {
71 |
72 | // Unconstrained catch all, we can stop early
73 | parameters ??= new(StringComparer.OrdinalIgnoreCase);
74 | parameters[templateSegment.Value] = string.Join('/', context.Segments, pathIndex, context.Segments.Length - pathIndex);
75 |
76 | // Mark the remaining segments as consumed.
77 | pathIndex = context.Segments.Length;
78 |
79 | // Catch-alls are always last.
80 | templateIndex++;
81 |
82 | // We are done, so break out of the loop.
83 | break;
84 | }
85 | else
86 | {
87 | // For constrained catch-alls, we advance the path index but keep the template index on the catch-all.
88 | pathIndex++;
89 | if (pathIndex == context.Segments.Length)
90 | {
91 | parameters ??= new(StringComparer.OrdinalIgnoreCase);
92 | parameters[templateSegment.Value] = string.Join('/', context.Segments, templateIndex, context.Segments.Length - templateIndex);
93 |
94 | // This is important to signal that we consumed the entire template.
95 | templateIndex++;
96 | }
97 | }
98 | }
99 | }
100 |
101 | var hasRemainingOptionalSegments = templateIndex < Template.Segments.Length &&
102 | RemainingSegmentsAreOptional(pathIndex, Template.Segments);
103 |
104 | if ((pathIndex == context.Segments.Length && templateIndex == Template.Segments.Length) || hasRemainingOptionalSegments)
105 | {
106 | if (hasRemainingOptionalSegments)
107 | {
108 | parameters ??= new Dictionary(StringComparer.Ordinal);
109 | AddDefaultValues(parameters, templateIndex, Template.Segments);
110 | }
111 | if (UnusedRouteParameterNames?.Count > 0)
112 | {
113 | parameters ??= new Dictionary(StringComparer.Ordinal);
114 | for (var i = 0; i < UnusedRouteParameterNames.Count; i++)
115 | {
116 | parameters[UnusedRouteParameterNames[i]] = null;
117 | }
118 | }
119 | context.Handler = Handler;
120 | context.Parameters = parameters;
121 | }
122 | }
123 |
124 | private void AddDefaultValues(Dictionary parameters, int templateIndex, TemplateSegment[] segments)
125 | {
126 | for (var i = templateIndex; i < segments.Length; i++)
127 | {
128 | var currentSegment = segments[i];
129 | parameters[currentSegment.Value] = null;
130 | }
131 | }
132 |
133 | private bool RemainingSegmentsAreOptional(int index, TemplateSegment[] segments)
134 | {
135 | for (var i = index; index < segments.Length - 1; index++)
136 | {
137 | if (!segments[i].IsOptional)
138 | {
139 | return false;
140 | }
141 | }
142 |
143 | return segments[^1].IsOptional || segments[^1].IsCatchAll;
144 | }
145 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.rsuser
8 | *.suo
9 | *.user
10 | *.userosscache
11 | *.sln.docstates
12 |
13 | # User-specific files (MonoDevelop/Xamarin Studio)
14 | *.userprefs
15 |
16 | # Mono auto generated files
17 | mono_crash.*
18 |
19 | # Build results
20 | [Dd]ebug/
21 | [Dd]ebugPublic/
22 | [Rr]elease/
23 | [Rr]eleases/
24 | x64/
25 | x86/
26 | [Ww][Ii][Nn]32/
27 | [Aa][Rr][Mm]/
28 | [Aa][Rr][Mm]64/
29 | bld/
30 | [Bb]in/
31 | [Oo]bj/
32 | [Oo]ut/
33 | [Ll]og/
34 | [Ll]ogs/
35 |
36 | # Visual Studio 2015/2017 cache/options directory
37 | .vs/
38 | # Uncomment if you have tasks that create the project's static files in wwwroot
39 | #wwwroot/
40 |
41 | # Visual Studio 2017 auto generated files
42 | Generated\ Files/
43 |
44 | # MSTest test Results
45 | [Tt]est[Rr]esult*/
46 | [Bb]uild[Ll]og.*
47 |
48 | # NUnit
49 | *.VisualState.xml
50 | TestResult.xml
51 | nunit-*.xml
52 |
53 | # Build Results of an ATL Project
54 | [Dd]ebugPS/
55 | [Rr]eleasePS/
56 | dlldata.c
57 |
58 | # Benchmark Results
59 | BenchmarkDotNet.Artifacts/
60 |
61 | # .NET Core
62 | project.lock.json
63 | project.fragment.lock.json
64 | artifacts/
65 |
66 | # ASP.NET Scaffolding
67 | ScaffoldingReadMe.txt
68 |
69 | # StyleCop
70 | StyleCopReport.xml
71 |
72 | # Files built by Visual Studio
73 | *_i.c
74 | *_p.c
75 | *_h.h
76 | *.ilk
77 | *.meta
78 | *.obj
79 | *.iobj
80 | *.pch
81 | *.pdb
82 | *.ipdb
83 | *.pgc
84 | *.pgd
85 | *.rsp
86 | *.sbr
87 | *.tlb
88 | *.tli
89 | *.tlh
90 | *.tmp
91 | *.tmp_proj
92 | *_wpftmp.csproj
93 | *.log
94 | *.vspscc
95 | *.vssscc
96 | .builds
97 | *.pidb
98 | *.svclog
99 | *.scc
100 |
101 | # Chutzpah Test files
102 | _Chutzpah*
103 |
104 | # Visual C++ cache files
105 | ipch/
106 | *.aps
107 | *.ncb
108 | *.opendb
109 | *.opensdf
110 | *.sdf
111 | *.cachefile
112 | *.VC.db
113 | *.VC.VC.opendb
114 |
115 | # Visual Studio profiler
116 | *.psess
117 | *.vsp
118 | *.vspx
119 | *.sap
120 |
121 | # Visual Studio Trace Files
122 | *.e2e
123 |
124 | # TFS 2012 Local Workspace
125 | $tf/
126 |
127 | # Guidance Automation Toolkit
128 | *.gpState
129 |
130 | # ReSharper is a .NET coding add-in
131 | _ReSharper*/
132 | *.[Rr]e[Ss]harper
133 | *.DotSettings.user
134 |
135 | # TeamCity is a build add-in
136 | _TeamCity*
137 |
138 | # DotCover is a Code Coverage Tool
139 | *.dotCover
140 |
141 | # AxoCover is a Code Coverage Tool
142 | .axoCover/*
143 | !.axoCover/settings.json
144 |
145 | # Coverlet is a free, cross platform Code Coverage Tool
146 | coverage*.json
147 | coverage*.xml
148 | coverage*.info
149 |
150 | # Visual Studio code coverage results
151 | *.coverage
152 | *.coveragexml
153 |
154 | # NCrunch
155 | _NCrunch_*
156 | .*crunch*.local.xml
157 | nCrunchTemp_*
158 |
159 | # MightyMoose
160 | *.mm.*
161 | AutoTest.Net/
162 |
163 | # Web workbench (sass)
164 | .sass-cache/
165 |
166 | # Installshield output folder
167 | [Ee]xpress/
168 |
169 | # DocProject is a documentation generator add-in
170 | DocProject/buildhelp/
171 | DocProject/Help/*.HxT
172 | DocProject/Help/*.HxC
173 | DocProject/Help/*.hhc
174 | DocProject/Help/*.hhk
175 | DocProject/Help/*.hhp
176 | DocProject/Help/Html2
177 | DocProject/Help/html
178 |
179 | # Click-Once directory
180 | publish/
181 |
182 | # Publish Web Output
183 | *.[Pp]ublish.xml
184 | *.azurePubxml
185 | # Note: Comment the next line if you want to checkin your web deploy settings,
186 | # but database connection strings (with potential passwords) will be unencrypted
187 | *.pubxml
188 | *.publishproj
189 |
190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
191 | # checkin your Azure Web App publish settings, but sensitive information contained
192 | # in these scripts will be unencrypted
193 | PublishScripts/
194 |
195 | # NuGet Packages
196 | *.nupkg
197 | # NuGet Symbol Packages
198 | *.snupkg
199 | # The packages folder can be ignored because of Package Restore
200 | **/[Pp]ackages/*
201 | # except build/, which is used as an MSBuild target.
202 | !**/[Pp]ackages/build/
203 | # Uncomment if necessary however generally it will be regenerated when needed
204 | #!**/[Pp]ackages/repositories.config
205 | # NuGet v3's project.json files produces more ignorable files
206 | *.nuget.props
207 | *.nuget.targets
208 |
209 | # Microsoft Azure Build Output
210 | csx/
211 | *.build.csdef
212 |
213 | # Microsoft Azure Emulator
214 | ecf/
215 | rcf/
216 |
217 | # Windows Store app package directories and files
218 | AppPackages/
219 | BundleArtifacts/
220 | Package.StoreAssociation.xml
221 | _pkginfo.txt
222 | *.appx
223 | *.appxbundle
224 | *.appxupload
225 |
226 | # Visual Studio cache files
227 | # files ending in .cache can be ignored
228 | *.[Cc]ache
229 | # but keep track of directories ending in .cache
230 | !?*.[Cc]ache/
231 |
232 | # Others
233 | ClientBin/
234 | ~$*
235 | *~
236 | *.dbmdl
237 | *.dbproj.schemaview
238 | *.jfm
239 | *.pfx
240 | *.publishsettings
241 | orleans.codegen.cs
242 |
243 | # Including strong name files can present a security risk
244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
245 | #*.snk
246 |
247 | # Since there are multiple workflows, uncomment next line to ignore bower_components
248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
249 | #bower_components/
250 |
251 | # RIA/Silverlight projects
252 | Generated_Code/
253 |
254 | # Backup & report files from converting an old project file
255 | # to a newer Visual Studio version. Backup files are not needed,
256 | # because we have git ;-)
257 | _UpgradeReport_Files/
258 | Backup*/
259 | UpgradeLog*.XML
260 | UpgradeLog*.htm
261 | ServiceFabricBackup/
262 | *.rptproj.bak
263 |
264 | # SQL Server files
265 | *.mdf
266 | *.ldf
267 | *.ndf
268 |
269 | # Business Intelligence projects
270 | *.rdl.data
271 | *.bim.layout
272 | *.bim_*.settings
273 | *.rptproj.rsuser
274 | *- [Bb]ackup.rdl
275 | *- [Bb]ackup ([0-9]).rdl
276 | *- [Bb]ackup ([0-9][0-9]).rdl
277 |
278 | # Microsoft Fakes
279 | FakesAssemblies/
280 |
281 | # GhostDoc plugin setting file
282 | *.GhostDoc.xml
283 |
284 | # Node.js Tools for Visual Studio
285 | .ntvs_analysis.dat
286 | node_modules/
287 |
288 | # Visual Studio 6 build log
289 | *.plg
290 |
291 | # Visual Studio 6 workspace options file
292 | *.opt
293 |
294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
295 | *.vbw
296 |
297 | # Visual Studio LightSwitch build output
298 | **/*.HTMLClient/GeneratedArtifacts
299 | **/*.DesktopClient/GeneratedArtifacts
300 | **/*.DesktopClient/ModelManifest.xml
301 | **/*.Server/GeneratedArtifacts
302 | **/*.Server/ModelManifest.xml
303 | _Pvt_Extensions
304 |
305 | # Paket dependency manager
306 | .paket/paket.exe
307 | paket-files/
308 |
309 | # FAKE - F# Make
310 | .fake/
311 |
312 | # CodeRush personal settings
313 | .cr/personal
314 |
315 | # Python Tools for Visual Studio (PTVS)
316 | __pycache__/
317 | *.pyc
318 |
319 | # Cake - Uncomment if you are using it
320 | # tools/**
321 | # !tools/packages.config
322 |
323 | # Tabs Studio
324 | *.tss
325 |
326 | # Telerik's JustMock configuration file
327 | *.jmconfig
328 |
329 | # BizTalk build output
330 | *.btp.cs
331 | *.btm.cs
332 | *.odx.cs
333 | *.xsd.cs
334 |
335 | # OpenCover UI analysis results
336 | OpenCover/
337 |
338 | # Azure Stream Analytics local run output
339 | ASALocalRun/
340 |
341 | # MSBuild Binary and Structured Log
342 | *.binlog
343 |
344 | # NVidia Nsight GPU debugger configuration file
345 | *.nvuser
346 |
347 | # MFractors (Xamarin productivity tool) working folder
348 | .mfractor/
349 |
350 | # Local History for Visual Studio
351 | .localhistory/
352 |
353 | # BeatPulse healthcheck temp database
354 | healthchecksdb
355 |
356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
357 | MigrationBackup/
358 |
359 | # Ionide (cross platform F# VS Code tools) working folder
360 | .ionide/
361 |
362 | # Fody - auto-generated XML schema
363 | FodyWeavers.xsd
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/UrlValueConstraint.cs:
--------------------------------------------------------------------------------
1 | // Licensed to the .NET Foundation under one or more agreements.
2 | // The .NET Foundation licenses this file to you under the MIT license.
3 |
4 | using System.Collections.Concurrent;
5 | using System.Diagnostics.CodeAnalysis;
6 | using System.Globalization;
7 |
8 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
9 |
10 | ///
11 | /// Shared logic for parsing tokens from route values and querystring values.
12 | ///
13 | internal abstract class UrlValueConstraint
14 | {
15 | public delegate bool TryParseDelegate(ReadOnlySpan str, [MaybeNullWhen(false)] out T result);
16 |
17 | private static readonly ConcurrentDictionary _cachedInstances = new();
18 |
19 | public static bool TryGetByTargetType(Type targetType, [MaybeNullWhen(false)] out UrlValueConstraint result)
20 | {
21 | if (!_cachedInstances.TryGetValue(targetType, out result))
22 | {
23 | result = Create(targetType);
24 | if (result is null)
25 | {
26 | return false;
27 | }
28 |
29 | _cachedInstances.TryAdd(targetType, result);
30 | }
31 |
32 | return true;
33 | }
34 |
35 | private static bool TryParse(ReadOnlySpan str, out string result)
36 | {
37 | result = str.ToString();
38 | return true;
39 | }
40 |
41 | private static bool TryParse(ReadOnlySpan str, out DateTime result)
42 | => DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result);
43 |
44 | private static bool TryParse(ReadOnlySpan str, out DateOnly result)
45 | => DateOnly.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result);
46 |
47 | private static bool TryParse(ReadOnlySpan str, out TimeOnly result)
48 | => TimeOnly.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result);
49 |
50 | private static bool TryParse(ReadOnlySpan str, out decimal result)
51 | => decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result);
52 |
53 | private static bool TryParse(ReadOnlySpan str, out double result)
54 | => double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result);
55 |
56 | private static bool TryParse(ReadOnlySpan str, out float result)
57 | => float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result);
58 |
59 | private static bool TryParse(ReadOnlySpan str, out int result)
60 | => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result);
61 |
62 | private static bool TryParse(ReadOnlySpan str, out long result)
63 | => long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result);
64 |
65 | private static UrlValueConstraint? Create(Type targetType) => targetType switch
66 | {
67 | var x when x == typeof(string) => new TypedUrlValueConstraint(TryParse),
68 | var x when x == typeof(bool) => new TypedUrlValueConstraint(bool.TryParse),
69 | var x when x == typeof(bool?) => new NullableTypedUrlValueConstraint(bool.TryParse),
70 | var x when x == typeof(DateTime) => new TypedUrlValueConstraint(TryParse),
71 | var x when x == typeof(DateTime?) => new NullableTypedUrlValueConstraint(TryParse),
72 | var x when x == typeof(DateOnly) => new TypedUrlValueConstraint(TryParse),
73 | var x when x == typeof(DateOnly?) => new NullableTypedUrlValueConstraint(TryParse),
74 | var x when x == typeof(TimeOnly) => new TypedUrlValueConstraint(TryParse),
75 | var x when x == typeof(TimeOnly?) => new NullableTypedUrlValueConstraint(TryParse),
76 | var x when x == typeof(decimal) => new TypedUrlValueConstraint(TryParse),
77 | var x when x == typeof(decimal?) => new NullableTypedUrlValueConstraint(TryParse),
78 | var x when x == typeof(double) => new TypedUrlValueConstraint(TryParse),
79 | var x when x == typeof(double?) => new NullableTypedUrlValueConstraint(TryParse),
80 | var x when x == typeof(float) => new TypedUrlValueConstraint(TryParse),
81 | var x when x == typeof(float?) => new NullableTypedUrlValueConstraint(TryParse),
82 | var x when x == typeof(Guid) => new TypedUrlValueConstraint(Guid.TryParse),
83 | var x when x == typeof(Guid?) => new NullableTypedUrlValueConstraint(Guid.TryParse),
84 | var x when x == typeof(int) => new TypedUrlValueConstraint(TryParse),
85 | var x when x == typeof(int?) => new NullableTypedUrlValueConstraint(TryParse),
86 | var x when x == typeof(long) => new TypedUrlValueConstraint(TryParse),
87 | var x when x == typeof(long?) => new NullableTypedUrlValueConstraint(TryParse),
88 | var x => null
89 | };
90 |
91 | public abstract bool TryParse(ReadOnlySpan value, [MaybeNullWhen(false)] out object result);
92 |
93 | public abstract object? Parse(ReadOnlySpan value, string destinationNameForMessage);
94 |
95 | public abstract Array ParseMultiple(StringSegmentAccumulator values, string destinationNameForMessage);
96 |
97 | private class TypedUrlValueConstraint : UrlValueConstraint
98 | {
99 | private readonly TryParseDelegate _parser;
100 |
101 | public TypedUrlValueConstraint(TryParseDelegate parser)
102 | {
103 | _parser = parser;
104 | }
105 |
106 | public override bool TryParse(ReadOnlySpan value, [MaybeNullWhen(false)] out object result)
107 | {
108 | if (_parser(value, out var typedResult))
109 | {
110 | result = typedResult!;
111 | return true;
112 | }
113 | else
114 | {
115 | result = null;
116 | return false;
117 | }
118 | }
119 |
120 | public override object? Parse(ReadOnlySpan value, string destinationNameForMessage)
121 | {
122 | if (!_parser(value, out var parsedValue))
123 | {
124 | throw new InvalidOperationException($"Cannot parse the value '{value.ToString()}' as type '{typeof(T)}' for '{destinationNameForMessage}'.");
125 | }
126 |
127 | return parsedValue;
128 | }
129 |
130 | public override Array ParseMultiple(StringSegmentAccumulator values, string destinationNameForMessage)
131 | {
132 | var count = values.Count;
133 | if (count == 0)
134 | {
135 | return Array.Empty();
136 | }
137 |
138 | var result = new T?[count];
139 |
140 | for (var i = 0; i < count; i++)
141 | {
142 | if (!_parser(values[i].Span, out result[i]))
143 | {
144 | throw new InvalidOperationException($"Cannot parse the value '{values[i]}' as type '{typeof(T)}' for '{destinationNameForMessage}'.");
145 | }
146 | }
147 |
148 | return result;
149 | }
150 | }
151 |
152 | private sealed class NullableTypedUrlValueConstraint : TypedUrlValueConstraint where T : struct
153 | {
154 | public NullableTypedUrlValueConstraint(TryParseDelegate parser)
155 | : base(SupportNullable(parser))
156 | {
157 | }
158 |
159 | private static TryParseDelegate SupportNullable(TryParseDelegate parser)
160 | {
161 | return TryParseNullable;
162 |
163 | bool TryParseNullable(ReadOnlySpan value, [MaybeNullWhen(false)] out T? result)
164 | {
165 | if (value.IsEmpty)
166 | {
167 | result = default;
168 | return true;
169 | }
170 | else if (parser(value, out var parsedValue))
171 | {
172 | result = parsedValue;
173 | return true;
174 | }
175 | else
176 | {
177 | result = default;
178 | return false;
179 | }
180 | }
181 | }
182 | }
183 | }
184 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Routing/RouteTableFactory.cs:
--------------------------------------------------------------------------------
1 | // Licensed to the .NET Foundation under one or more agreements.
2 | // The .NET Foundation licenses this file to you under the MIT license.
3 |
4 | using Microsoft.AspNetCore.Components;
5 | using System.Collections.Concurrent;
6 | using System.Reflection;
7 |
8 | namespace SpawnDev.BlazorJS.IndexRouting.Routing;
9 |
10 | ///
11 | /// Resolves components for an application.
12 | ///
13 | internal static class RouteTableFactory
14 | {
15 | private static readonly ConcurrentDictionary Cache = new();
16 | public static readonly IComparer RoutePrecedence = Comparer.Create(RouteComparison);
17 |
18 | public static RouteTable Create(RouteKey routeKey)
19 | {
20 | if (Cache.TryGetValue(routeKey, out var resolvedComponents))
21 | {
22 | return resolvedComponents;
23 | }
24 |
25 | var componentTypes = GetRouteableComponents(routeKey);
26 | var routeTable = Create(componentTypes);
27 | Cache.TryAdd(routeKey, routeTable);
28 | return routeTable;
29 | }
30 |
31 | public static void ClearCaches() => Cache.Clear();
32 |
33 | private static List GetRouteableComponents(RouteKey routeKey)
34 | {
35 | var routeableComponents = new List();
36 | if (routeKey.AppAssembly is not null)
37 | {
38 | GetRouteableComponents(routeableComponents, routeKey.AppAssembly);
39 | }
40 |
41 | if (routeKey.AdditionalAssemblies is not null)
42 | {
43 | foreach (var assembly in routeKey.AdditionalAssemblies)
44 | {
45 | GetRouteableComponents(routeableComponents, assembly);
46 | }
47 | }
48 |
49 | return routeableComponents;
50 |
51 | static void GetRouteableComponents(List routeableComponents, Assembly assembly)
52 | {
53 | foreach (var type in assembly.ExportedTypes)
54 | {
55 | if (typeof(IComponent).IsAssignableFrom(type) && type.IsDefined(typeof(RouteAttribute)))
56 | {
57 | routeableComponents.Add(type);
58 | }
59 | }
60 | }
61 | }
62 |
63 | internal static RouteTable Create(List componentTypes)
64 | {
65 | var templatesByHandler = new Dictionary();
66 | foreach (var componentType in componentTypes)
67 | {
68 | // We're deliberately using inherit = false here.
69 | //
70 | // RouteAttribute is defined as non-inherited, because inheriting a route attribute always causes an
71 | // ambiguity. You end up with two components (base class and derived class) with the same route.
72 | var routeAttributes = componentType.GetCustomAttributes(typeof(RouteAttribute), inherit: false);
73 | var templates = new string[routeAttributes.Length];
74 | for (var i = 0; i < routeAttributes.Length; i++)
75 | {
76 | var attribute = (RouteAttribute)routeAttributes[i];
77 | templates[i] = attribute.Template;
78 | }
79 |
80 | templatesByHandler.Add(componentType, templates);
81 | }
82 | return Create(templatesByHandler);
83 | }
84 |
85 | internal static RouteTable Create(Dictionary templatesByHandler)
86 | {
87 | var routes = new List();
88 | foreach (var (type, templates) in templatesByHandler)
89 | {
90 | var allRouteParameterNames = new HashSet(StringComparer.OrdinalIgnoreCase);
91 | var parsedTemplates = new (RouteTemplate, HashSet)[templates.Length];
92 | for (var i = 0; i < templates.Length; i++)
93 | {
94 | var parsedTemplate = TemplateParser.ParseTemplate(templates[i]);
95 | var parameterNames = GetParameterNames(parsedTemplate);
96 | parsedTemplates[i] = (parsedTemplate, parameterNames);
97 |
98 | foreach (var parameterName in parameterNames)
99 | {
100 | allRouteParameterNames.Add(parameterName);
101 | }
102 | }
103 |
104 | foreach (var (parsedTemplate, routeParameterNames) in parsedTemplates)
105 | {
106 | var unusedRouteParameterNames = GetUnusedParameterNames(allRouteParameterNames, routeParameterNames);
107 | var entry = new RouteEntry(parsedTemplate, type, unusedRouteParameterNames);
108 | routes.Add(entry);
109 | }
110 | }
111 |
112 | routes.Sort(RoutePrecedence);
113 | return new RouteTable(routes.ToArray());
114 | }
115 |
116 | private static HashSet GetParameterNames(RouteTemplate routeTemplate)
117 | {
118 | var parameterNames = new HashSet(StringComparer.OrdinalIgnoreCase);
119 | foreach (var segment in routeTemplate.Segments)
120 | {
121 | if (segment.IsParameter)
122 | {
123 | parameterNames.Add(segment.Value);
124 | }
125 | }
126 |
127 | return parameterNames;
128 | }
129 |
130 | private static List? GetUnusedParameterNames(HashSet allRouteParameterNames, HashSet routeParameterNames)
131 | {
132 | List? unusedParameters = null;
133 | foreach (var item in allRouteParameterNames)
134 | {
135 | if (!routeParameterNames.Contains(item))
136 | {
137 | unusedParameters ??= new();
138 | unusedParameters.Add(item);
139 | }
140 | }
141 |
142 | return unusedParameters;
143 | }
144 |
145 | ///
146 | /// Route precedence algorithm.
147 | /// We collect all the routes and sort them from most specific to
148 | /// less specific. The specificity of a route is given by the specificity
149 | /// of its segments and the position of those segments in the route.
150 | /// * A literal segment is more specific than a parameter segment.
151 | /// * A parameter segment with more constraints is more specific than one with fewer constraints
152 | /// * Segment earlier in the route are evaluated before segments later in the route.
153 | /// For example:
154 | /// /Literal is more specific than /Parameter
155 | /// /Route/With/{parameter} is more specific than /{multiple}/With/{parameters}
156 | /// /Product/{id:int} is more specific than /Product/{id}
157 | ///
158 | /// Routes can be ambiguous if:
159 | /// They are composed of literals and those literals have the same values (case insensitive)
160 | /// They are composed of a mix of literals and parameters, in the same relative order and the
161 | /// literals have the same values.
162 | /// For example:
163 | /// * /literal and /Literal
164 | /// /{parameter}/literal and /{something}/literal
165 | /// /{parameter:constraint}/literal and /{something:constraint}/literal
166 | ///
167 | /// To calculate the precedence we sort the list of routes as follows:
168 | /// * Shorter routes go first.
169 | /// * A literal wins over a parameter in precedence.
170 | /// * For literals with different values (case insensitive) we choose the lexical order
171 | /// * For parameters with different numbers of constraints, the one with more wins
172 | /// If we get to the end of the comparison routing we've detected an ambiguous pair of routes.
173 | ///
174 | internal static int RouteComparison(RouteEntry x, RouteEntry y)
175 | {
176 | if (ReferenceEquals(x, y))
177 | {
178 | return 0;
179 | }
180 |
181 | var xTemplate = x.Template;
182 | var yTemplate = y.Template;
183 | var minSegments = Math.Min(xTemplate.Segments.Length, yTemplate.Segments.Length);
184 | var currentResult = 0;
185 | for (var i = 0; i < minSegments; i++)
186 | {
187 | var xSegment = xTemplate.Segments[i];
188 | var ySegment = yTemplate.Segments[i];
189 |
190 | var xRank = GetRank(xSegment);
191 | var yRank = GetRank(ySegment);
192 |
193 | currentResult = xRank.CompareTo(yRank);
194 |
195 | // If they are both literals we can disambiguate
196 | if ((xRank, yRank) == (0, 0))
197 | {
198 | currentResult = StringComparer.OrdinalIgnoreCase.Compare(xSegment.Value, ySegment.Value);
199 | }
200 |
201 | if (currentResult != 0)
202 | {
203 | break;
204 | }
205 | }
206 |
207 | if (currentResult == 0)
208 | {
209 | currentResult = xTemplate.Segments.Length.CompareTo(yTemplate.Segments.Length);
210 | }
211 |
212 | if (currentResult == 0)
213 | {
214 | throw new InvalidOperationException($@"The following routes are ambiguous:
215 | '{x.Template.TemplateText}' in '{x.Handler.FullName}'
216 | '{y.Template.TemplateText}' in '{y.Handler.FullName}'
217 | ");
218 | }
219 |
220 | return currentResult;
221 | }
222 |
223 | private static int GetRank(TemplateSegment xSegment)
224 | {
225 | return xSegment switch
226 | {
227 | // Literal
228 | { IsParameter: false } => 0,
229 | // Parameter with constraints
230 | { IsParameter: true, IsCatchAll: false, Constraints: { Length: > 0 } } => 1,
231 | // Parameter without constraints
232 | { IsParameter: true, IsCatchAll: false, Constraints: { Length: 0 } } => 2,
233 | // Catch all parameter with constraints
234 | { IsParameter: true, IsCatchAll: true, Constraints: { Length: > 0 } } => 3,
235 | // Catch all parameter without constraints
236 | { IsParameter: true, IsCatchAll: true, Constraints: { Length: 0 } } => 4,
237 | // The segment is not correct
238 | _ => throw new InvalidOperationException($"Unknown segment definition '{xSegment}.")
239 | };
240 | }
241 | }
242 |
--------------------------------------------------------------------------------
/SpawnDev.BlazorJS.IndexRouting/Components/IndexRouter.cs:
--------------------------------------------------------------------------------
1 | #nullable disable warnings
2 |
3 | using Microsoft.Extensions.Logging;
4 | using System.Reflection;
5 | using System.Reflection.Metadata;
6 | using System.Runtime.ExceptionServices;
7 | using System.Web;
8 |
9 | namespace SpawnDev.BlazorJS.IndexRouting.Routing
10 | {
11 | ///
12 | /// Routes all pages to index.html?$={route} and then routes to the appropriate component
13 | ///
14 | public partial class IndexRouter : IComponent, IHandleAfterRender, IDisposable
15 | {
16 |
17 | private static string RouteQueryParameterName { get; set; } = "$";
18 |
19 | private static string IndexHtmlFile { get; set; } = "index.html";
20 |
21 | static readonly char[] _queryOrHashStartChar = new[] { '?', '#' };
22 | // Dictionary is intentionally used instead of ReadOnlyDictionary to reduce Blazor size
23 | static readonly IReadOnlyDictionary _emptyParametersDictionary
24 | = new Dictionary();
25 |
26 | RenderHandle _renderHandle;
27 | string _baseUri;
28 | string _locationAbsolute;
29 | bool _navigationInterceptionEnabled;
30 | ILogger _logger;
31 |
32 | private CancellationTokenSource _onNavigateCts;
33 |
34 | private Task _previousOnNavigateTask = Task.CompletedTask;
35 |
36 | private RouteKey _routeTableLastBuiltForRouteKey;
37 |
38 | private bool _onNavigateCalled;
39 |
40 | [Inject] private NavigationManager NavigationManager { get; set; }
41 |
42 | [Inject] private INavigationInterception NavigationInterception { get; set; }
43 |
44 | [Inject] private ILoggerFactory LoggerFactory { get; set; }
45 |
46 | ///
47 | /// Gets or sets the assembly that should be searched for components matching the URI.
48 | ///
49 | [Parameter]
50 | [EditorRequired]
51 | public Assembly AppAssembly { get; set; }
52 |
53 | ///
54 | /// Gets or sets a collection of additional assemblies that should be searched for components
55 | /// that can match URIs.
56 | ///
57 | [Parameter] public IEnumerable AdditionalAssemblies { get; set; }
58 |
59 | ///
60 | /// Gets or sets the content to display when no match is found for the requested route.
61 | ///
62 | [Parameter]
63 | [EditorRequired]
64 | public RenderFragment NotFound { get; set; }
65 |
66 | ///
67 | /// Gets or sets the content to display when a match is found for the requested route.
68 | ///
69 | [Parameter]
70 | [EditorRequired]
71 | public RenderFragment Found { get; set; }
72 |
73 | ///
74 | /// Get or sets the content to display when asynchronous navigation is in progress.
75 | ///
76 | [Parameter] public RenderFragment? Navigating { get; set; }
77 |
78 | ///
79 | /// Gets or sets a handler that should be called before navigating to a new page.
80 | ///
81 | [Parameter] public EventCallback OnNavigateAsync { get; set; }
82 |
83 | ///
84 | /// Gets or sets a flag to indicate whether route matching should prefer exact matches
85 | /// over wildcards.
86 | /// This property is obsolete and configuring it does nothing.
87 | ///
88 | [Parameter] public bool PreferExactMatches { get; set; }
89 |
90 | private RouteTable Routes { get; set; }
91 |
92 | ///
93 | public void Attach(RenderHandle renderHandle)
94 | {
95 | _logger = LoggerFactory.CreateLogger();
96 | _renderHandle = renderHandle;
97 | _baseUri = NavigationManager.BaseUri;
98 | _locationAbsolute = NavigationManager.Uri;
99 | NavigationManager.LocationChanged += OnLocationChanged;
100 |
101 | VerifyIndexPage();
102 | }
103 |
104 | ///
105 | public async Task SetParametersAsync(ParameterView parameters)
106 | {
107 | parameters.SetParameterProperties(this);
108 |
109 | if (AppAssembly == null)
110 | {
111 | throw new InvalidOperationException($"The {nameof(Router)} component requires a value for the parameter {nameof(AppAssembly)}.");
112 | }
113 |
114 | // Found content is mandatory, because even though we could use something like as a
115 | // reasonable default, if it's not declared explicitly in the template then people will have no way
116 | // to discover how to customize this (e.g., to add authorization).
117 | if (Found == null)
118 | {
119 | throw new InvalidOperationException($"The {nameof(Router)} component requires a value for the parameter {nameof(Found)}.");
120 | }
121 |
122 | // NotFound content is mandatory, because even though we could display a default message like "Not found",
123 | // it has to be specified explicitly so that it can also be wrapped in a specific layout
124 | if (NotFound == null)
125 | {
126 | throw new InvalidOperationException($"The {nameof(Router)} component requires a value for the parameter {nameof(NotFound)}.");
127 | }
128 |
129 | if (!_onNavigateCalled)
130 | {
131 | _onNavigateCalled = true;
132 | await RunOnNavigateAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), isNavigationIntercepted: false);
133 | }
134 |
135 | Refresh(isNavigationIntercepted: false);
136 | }
137 |
138 | ///
139 | public void Dispose()
140 | {
141 | NavigationManager.LocationChanged -= OnLocationChanged;
142 | }
143 |
144 | private static string StringUntilAny(string str, char[] chars)
145 | {
146 | var firstIndex = str.IndexOfAny(chars);
147 | return firstIndex < 0
148 | ? str
149 | : str.Substring(0, firstIndex);
150 | }
151 |
152 | private void RefreshRouteTable()
153 | {
154 | var routeKey = new RouteKey(AppAssembly, AdditionalAssemblies);
155 |
156 | if (!routeKey.Equals(_routeTableLastBuiltForRouteKey))
157 | {
158 | _routeTableLastBuiltForRouteKey = routeKey;
159 | Routes = RouteTableFactory.Create(routeKey);
160 | }
161 | }
162 |
163 | ///
164 | /// Typically called when hot reload is triggered to clear the route caches so changes are picked up.
165 | ///
166 | private void ClearRouteCaches()
167 | {
168 | RouteTableFactory.ClearCaches();
169 | _routeTableLastBuiltForRouteKey = default;
170 | }
171 |
172 | internal virtual void Refresh(bool isNavigationIntercepted)
173 | {
174 | // If an `OnNavigateAsync` task is currently in progress, then wait
175 | // for it to complete before rendering. Note: because _previousOnNavigateTask
176 | // is initialized to a CompletedTask on initialization, this will still
177 | // allow first-render to complete successfully.
178 | if (_previousOnNavigateTask.Status != TaskStatus.RanToCompletion)
179 | {
180 | if (Navigating != null)
181 | {
182 | _renderHandle.Render(Navigating);
183 | }
184 | return;
185 | }
186 |
187 | RefreshRouteTable();
188 |
189 | //
190 | var locationAbsoluteUri = new Uri(_locationAbsolute);
191 | var relativeAddress = new Uri(NavigationManager.BaseUri).MakeRelativeUri(locationAbsoluteUri);
192 |
193 | var locationPath = NavigationManager.ToBaseRelativePath(_locationAbsolute);
194 | locationPath = StringUntilAny(locationPath, _queryOrHashStartChar);
195 | // if the path is index.html we replace it with the route query parameter value
196 | if (locationPath == IndexHtmlFile)
197 | {
198 | locationPath = HttpUtility.ParseQueryString(locationAbsoluteUri.Query).Get(RouteQueryParameterName) ?? "";
199 | }
200 | var context = new RouteContext(locationPath);
201 | Routes.Route(context);
202 |
203 | if (context.Handler != null)
204 | {
205 | if (!typeof(IComponent).IsAssignableFrom(context.Handler))
206 | {
207 | throw new InvalidOperationException($"The type {context.Handler.FullName} " +
208 | $"does not implement {typeof(IComponent).FullName}.");
209 | }
210 |
211 | Log.NavigatingToComponent(_logger, context.Handler, locationPath, _baseUri);
212 |
213 | var routeData = new RouteData(
214 | context.Handler,
215 | context.Parameters ?? _emptyParametersDictionary);
216 | _renderHandle.Render(Found(routeData));
217 | }
218 | else
219 | {
220 | if (!isNavigationIntercepted)
221 | {
222 | Log.DisplayingNotFound(_logger, locationPath, _baseUri);
223 |
224 | // We did not find a Component that matches the route.
225 | // Only show the NotFound content if the application developer programatically got us here i.e we did not
226 | // intercept the navigation. In all other cases, force a browser navigation since this could be non-Blazor content.
227 | _renderHandle.Render(NotFound);
228 | }
229 | else
230 | {
231 | Log.NavigatingToExternalUri(_logger, _locationAbsolute, locationPath, _baseUri);
232 | NavigationManager.NavigateTo(_locationAbsolute, forceLoad: true);
233 | }
234 | }
235 | }
236 |
237 | internal async ValueTask RunOnNavigateAsync(string path, bool isNavigationIntercepted)
238 | {
239 | // Cancel the CTS instead of disposing it, since disposing does not
240 | // actually cancel and can cause unintended Object Disposed Exceptions.
241 | // This effectivelly cancels the previously running task and completes it.
242 | _onNavigateCts?.Cancel();
243 | // Then make sure that the task has been completely cancelled or completed
244 | // before starting the next one. This avoid race conditions where the cancellation
245 | // for the previous task was set but not fully completed by the time we get to this
246 | // invocation.
247 | await _previousOnNavigateTask;
248 |
249 | var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
250 | _previousOnNavigateTask = tcs.Task;
251 |
252 | if (!OnNavigateAsync.HasDelegate)
253 | {
254 | Refresh(isNavigationIntercepted);
255 | }
256 |
257 | _onNavigateCts = new CancellationTokenSource();
258 | var navigateContext = new NavigationContext(path, _onNavigateCts.Token);
259 |
260 | var cancellationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
261 | navigateContext.CancellationToken.Register(state =>
262 | ((TaskCompletionSource)state).SetResult(), cancellationTcs);
263 |
264 | try
265 | {
266 | // Task.WhenAny returns a Task so we need to await twice to unwrap the exception
267 | var task = await Task.WhenAny(OnNavigateAsync.InvokeAsync(navigateContext), cancellationTcs.Task);
268 | await task;
269 | tcs.SetResult();
270 | Refresh(isNavigationIntercepted);
271 | }
272 | catch (Exception e)
273 | {
274 | _renderHandle.Render(builder => ExceptionDispatchInfo.Throw(e));
275 | }
276 | }
277 | ///
278 | /// If true, navigation to any page except index.html will be redirected to index.html with the route as a query parameter
279 | ///
280 | [Parameter]
281 | public bool IndexLock { get; set; } = true;
282 | bool VerifyIndexPage()
283 | {
284 | if (!IndexLock)
285 | {
286 | return true;
287 | }
288 | var indexUrl = MakeIndexUrl(NavigationManager);
289 | if (indexUrl != null)
290 | {
291 | NavigationManager.NavigateTo(indexUrl, false, true);
292 | return false;
293 | }
294 | return true;
295 | }
296 | ///
297 | /// Creates a url for the specified location that will render using index.html as the path and the route appended as a query parameter
298 | ///
299 | public static string? MakeIndexUrl(NavigationManager navigationManager)
300 | {
301 | return MakeIndexUrl(navigationManager, navigationManager.Uri);
302 | }
303 | ///
304 | /// Creates a url for the specified location that will render using index.html as the path and the route appended as a query parameter
305 | ///
306 | ///
307 | ///
308 | ///
309 | public static string? MakeIndexUrl(NavigationManager navigationManager, string location)
310 | {
311 | var locationPathSub = navigationManager.ToBaseRelativePath(location);
312 | var locationPath = StringUntilAny(locationPathSub, _queryOrHashStartChar);
313 | if (locationPath != IndexHtmlFile)
314 | {
315 | var locationUri = new Uri(location);
316 | var qs = HttpUtility.ParseQueryString(locationUri.Query);
317 | if (string.IsNullOrEmpty(locationPath))
318 | {
319 | qs.Remove(RouteQueryParameterName);
320 | }
321 | else
322 | {
323 | qs.Set(RouteQueryParameterName, locationPath);
324 | }
325 | var newQuery = qs.ToString();
326 | var newPath = $"{IndexHtmlFile}{(string.IsNullOrEmpty(newQuery) ? "" : $"?{newQuery}")}";
327 | return newPath;
328 | }
329 | return null;
330 | }
331 | private void OnLocationChanged(object sender, LocationChangedEventArgs args)
332 | {
333 | if (!VerifyIndexPage())
334 | {
335 | // page will change
336 | return;
337 | }
338 | _locationAbsolute = args.Location;
339 | if (_renderHandle.IsInitialized && Routes != null)
340 | {
341 | _ = RunOnNavigateAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), args.IsNavigationIntercepted).Preserve();
342 | }
343 | }
344 |
345 | Task IHandleAfterRender.OnAfterRenderAsync()
346 | {
347 | if (!_navigationInterceptionEnabled)
348 | {
349 | _navigationInterceptionEnabled = true;
350 | return NavigationInterception.EnableNavigationInterceptionAsync();
351 | }
352 |
353 | return Task.CompletedTask;
354 | }
355 |
356 | private static partial class Log
357 | {
358 | [LoggerMessage(1, LogLevel.Debug, $"Displaying {nameof(NotFound)} because path '{{Path}}' with base URI '{{BaseUri}}' does not match any component route", EventName = "DisplayingNotFound")]
359 | internal static partial void DisplayingNotFound(ILogger logger, string path, string baseUri);
360 |
361 | [LoggerMessage(2, LogLevel.Debug, "Navigating to component {ComponentType} in response to path '{Path}' with base URI '{BaseUri}'", EventName = "NavigatingToComponent")]
362 | internal static partial void NavigatingToComponent(ILogger logger, Type componentType, string path, string baseUri);
363 |
364 | [LoggerMessage(3, LogLevel.Debug, "Navigating to non-component URI '{ExternalUri}' in response to path '{Path}' with base URI '{BaseUri}'", EventName = "NavigatingToExternalUri")]
365 | internal static partial void NavigatingToExternalUri(ILogger logger, string externalUri, string path, string baseUri);
366 | }
367 | }
368 | }
--------------------------------------------------------------------------------