File: ApplicationModels\PageRouteModelFactory.cs
Web Access
Project: src\src\Mvc\Mvc.RazorPages\src\Microsoft.AspNetCore.Mvc.RazorPages.csproj (Microsoft.AspNetCore.Mvc.RazorPages)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.AspNetCore.Mvc.ApplicationModels;
 
internal sealed partial class PageRouteModelFactory
{
    private static readonly string IndexFileName = "Index" + RazorViewEngine.ViewExtension;
    private readonly RazorPagesOptions _options;
    private readonly ILogger _logger;
    private readonly string _normalizedRootDirectory;
    private readonly string _normalizedAreaRootDirectory;
 
    public PageRouteModelFactory(
        RazorPagesOptions options,
        ILogger logger)
    {
        _options = options ?? throw new ArgumentNullException(nameof(options));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 
        _normalizedRootDirectory = NormalizeDirectory(options.RootDirectory);
        _normalizedAreaRootDirectory = "/Areas/";
    }
 
    public PageRouteModel CreateRouteModel(string relativePath, string? routeTemplate)
    {
        var viewEnginePath = GetViewEnginePath(_normalizedRootDirectory, relativePath);
        var routeModel = new PageRouteModel(relativePath, viewEnginePath);
 
        PopulateRouteModel(routeModel, viewEnginePath, routeTemplate);
 
        return routeModel;
    }
 
    public PageRouteModel? CreateAreaRouteModel(string relativePath, string? routeTemplate)
    {
        if (!TryParseAreaPath(relativePath, out var areaResult))
        {
            return null;
        }
 
        var routeModel = new PageRouteModel(relativePath, areaResult.viewEnginePath, areaResult.areaName);
 
        var routePrefix = CreateAreaRoute(areaResult.areaName, areaResult.viewEnginePath);
        PopulateRouteModel(routeModel, routePrefix, routeTemplate);
        routeModel.RouteValues["area"] = areaResult.areaName;
 
        return routeModel;
    }
 
    private static void PopulateRouteModel(PageRouteModel model, string pageRoute, string? routeTemplate)
    {
        model.RouteValues.Add("page", model.ViewEnginePath);
 
        var selectorModel = CreateSelectorModel(pageRoute, routeTemplate);
        model.Selectors.Add(selectorModel);
 
        var fileName = Path.GetFileName(model.RelativePath);
        if (!AttributeRouteModel.IsOverridePattern(routeTemplate) &&
            string.Equals(IndexFileName, fileName, StringComparison.OrdinalIgnoreCase))
        {
            // For pages without an override route, and ending in /Index.cshtml, we want to allow
            // incoming routing, but force outgoing routes to match to the path sans /Index.
            selectorModel.AttributeRouteModel!.SuppressLinkGeneration = true;
 
            var index = pageRoute.LastIndexOf('/');
            var parentDirectoryPath = index == -1 ?
                string.Empty :
                pageRoute.Substring(0, index);
            model.Selectors.Add(CreateSelectorModel(parentDirectoryPath, routeTemplate));
        }
    }
 
    // Internal for unit testing
    internal bool TryParseAreaPath(
        string relativePath,
        out (string areaName, string viewEnginePath) result)
    {
        // path = "/Areas/Products/Pages/Manage/Home.cshtml"
        // Result ("Products", "/Manage/Home")
        const string AreaPagesRoot = "/Pages/";
 
        result = default;
        Debug.Assert(relativePath.StartsWith('/'));
        // Parse the area root directory.
        var areaRootEndIndex = relativePath.IndexOf('/', startIndex: 1);
        if (areaRootEndIndex == -1 ||
            areaRootEndIndex >= relativePath.Length - 1 || // There's at least one token after the area root.
            !relativePath.StartsWith(_normalizedAreaRootDirectory, StringComparison.OrdinalIgnoreCase)) // The path must start with area root.
        {
            Log.UnsupportedAreaPath(_logger, relativePath);
            return false;
        }
 
        // The first directory that follows the area root is the area name.
        var areaEndIndex = relativePath.IndexOf('/', startIndex: areaRootEndIndex + 1);
        if (areaEndIndex == -1 || areaEndIndex == relativePath.Length)
        {
            Log.UnsupportedAreaPath(_logger, relativePath);
            return false;
        }
 
        var areaName = relativePath.Substring(areaRootEndIndex + 1, areaEndIndex - areaRootEndIndex - 1);
        // Ensure the next token is the "Pages" directory
        if (string.Compare(relativePath, areaEndIndex, AreaPagesRoot, 0, AreaPagesRoot.Length, StringComparison.OrdinalIgnoreCase) != 0)
        {
            Log.UnsupportedAreaPath(_logger, relativePath);
            return false;
        }
 
        // Include the trailing slash of the root directory at the start of the viewEnginePath
        var pageNameIndex = areaEndIndex + AreaPagesRoot.Length - 1;
        var viewEnginePath = relativePath.Substring(pageNameIndex, relativePath.Length - pageNameIndex - RazorViewEngine.ViewExtension.Length);
 
        result = (areaName, viewEnginePath);
        return true;
    }
 
    private static string GetViewEnginePath(string rootDirectory, string path)
    {
        // rootDirectory = "/Pages/AllMyPages/"
        // path = "/Pages/AllMyPages/Home.cshtml"
        // Result = "/Home"
        Debug.Assert(path.StartsWith(rootDirectory, StringComparison.OrdinalIgnoreCase));
        Debug.Assert(path.EndsWith(RazorViewEngine.ViewExtension, StringComparison.OrdinalIgnoreCase));
        var startIndex = rootDirectory.Length - 1;
        var endIndex = path.Length - RazorViewEngine.ViewExtension.Length;
        return path.Substring(startIndex, endIndex - startIndex);
    }
 
    private static string CreateAreaRoute(string areaName, string viewEnginePath)
    {
        // AreaName = Products, ViewEnginePath = /List/Categories
        // Result = /Products/List/Categories
        Debug.Assert(!string.IsNullOrEmpty(areaName));
        Debug.Assert(!string.IsNullOrEmpty(viewEnginePath));
        Debug.Assert(viewEnginePath.StartsWith('/'));
 
        return string.Create(1 + areaName.Length + viewEnginePath.Length, (areaName, viewEnginePath), (span, tuple) =>
        {
            var (areaNameValue, viewEnginePathValue) = tuple;
 
            span[0] = '/';
            span = span.Slice(1);
 
            areaNameValue.AsSpan().CopyTo(span);
            span = span.Slice(areaNameValue.Length);
 
            viewEnginePathValue.AsSpan().CopyTo(span);
        });
    }
 
    private static SelectorModel CreateSelectorModel(string prefix, string? routeTemplate)
    {
        return new SelectorModel
        {
            AttributeRouteModel = new AttributeRouteModel
            {
                Template = AttributeRouteModel.CombineTemplates(prefix, routeTemplate),
            },
            EndpointMetadata =
                {
                    new PageRouteMetadata(prefix, routeTemplate)
                }
        };
    }
 
    private static string NormalizeDirectory(string directory)
    {
        Debug.Assert(directory.StartsWith('/'));
        if (directory.Length > 1 && !directory.EndsWith('/'))
        {
            return directory + "/";
        }
 
        return directory;
    }
 
    private static partial class Log
    {
        [LoggerMessage(1, LogLevel.Warning, "The page at '{FilePath}' is located under the area root directory '/Areas/' but does not follow the path format '/Areas/AreaName/Pages/Directory/FileName.cshtml", EventName = "UnsupportedAreaPath")]
        public static partial void UnsupportedAreaPath(ILogger log, string filePath);
    }
}