| File: Language\Components\ComponentTildePathPass.cs | Web Access |
| Project: src\roslyn\src\Razor\src\Compiler\Microsoft.CodeAnalysis.Razor.Compiler\src\Microsoft.CodeAnalysis.Razor.Compiler.csproj (Microsoft.CodeAnalysis.Razor.Compiler) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; using System.Collections.Generic; using System.Threading; using Microsoft.AspNetCore.Razor.Language.Intermediate; namespace Microsoft.AspNetCore.Razor.Language.Components; /// <summary> /// Rewrites <c>~/</c>-prefixed string literal attribute values into <c>Assets["path"]</c> C# /// expressions, but only where the target has explicitly opted into asset-path expansion: /// <list type="bullet"> /// <item>HTML element attributes whose (element, attribute) pair the runtime declared via /// <c>[AcceptsAssetPath(elementName, attributeName)]</c> (surfaced as <see cref="AssetPathMetadata"/> /// tag helpers).</item> /// <item>Component parameters whose property is marked with <c>[AssetPath]</c> /// (<see cref="PropertyMetadata.AcceptsAssetPath"/>).</item> /// </list> /// For example, <c><img src="~/images/logo.png" /></c> becomes <c>Assets["images/logo.png"]</c> /// when (img, src) is opted in. When nothing is opted in, no expansion occurs. /// </summary> /// <remarks> /// Runs after <c>ComponentLoweringPass</c> (Order=0) and the Order=50 passes, but before /// <c>ComponentBindLoweringPass</c> (Order=100). /// </remarks> internal sealed class ComponentTildePathPass(RazorLanguageVersion version) : ComponentIntermediateNodePassBase, IRazorOptimizationPass { private const string TildePrefix = "~/"; // The allowlist derives solely from the engine's discovered tag helpers, which are fixed for // the lifetime of this pass instance, so it is computed once and shared across every document. private Dictionary<string, HashSet<string>>? _allowedElementAttributes; public override int Order => 75; protected override void ExecuteCore( RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode, CancellationToken cancellationToken) { if (!IsComponentDocument(documentNode)) { return; } if (version < RazorLanguageVersion.Version_11_0) { return; } var rewriter = new Rewriter(GetAllowedElementAttributes(cancellationToken)); rewriter.Visit(documentNode); } private Dictionary<string, HashSet<string>> GetAllowedElementAttributes(CancellationToken cancellationToken) { // The allowlist is idempotent, so a benign race just builds it twice; CompareExchange keeps // whichever result lands first and discards the other, and no lock is needed. if (_allowedElementAttributes is null) { Interlocked.CompareExchange(ref _allowedElementAttributes, BuildAllowedElementAttributes(cancellationToken), null); } return _allowedElementAttributes; } private Dictionary<string, HashSet<string>> BuildAllowedElementAttributes(CancellationToken cancellationToken) { // Maps an opted-in HTML element name to the set of its attributes that accept asset paths. // Both element and attribute comparisons are case-insensitive, matching HTML semantics. The // allowlist is global: it comes from the full set of discovered tag helpers (which includes // the AssetPathMetadata carriers produced from [AcceptsAssetPath]), independent of which tag // helpers are in scope for this document. An empty map means nothing is opted in. var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase); if (!Engine.TryGetFeature(out ITagHelperFeature? tagHelperFeature)) { return result; } foreach (var tagHelper in tagHelperFeature.GetTagHelpers(cancellationToken)) { if (tagHelper.Metadata is not AssetPathMetadata { Element: var element, Attribute: var attribute }) { continue; } if (!result.TryGetValue(element, out var attributes)) { attributes = new(StringComparer.OrdinalIgnoreCase); result.Add(element, attributes); } attributes.Add(attribute); } return result; } private sealed class Rewriter(Dictionary<string, HashSet<string>> allowedElementAttributes) : IntermediateNodeWalker { private string? _currentElementName; public override void VisitMarkupElement(MarkupElementIntermediateNode node) { var previous = _currentElementName; _currentElementName = node.TagName; base.VisitMarkupElement(node); _currentElementName = previous; } // HTML element attributes: // HtmlAttributeIntermediateNode // -> HtmlAttributeValueIntermediateNode (exactly one child) // -> IntermediateToken (Content starts with "~/") public override void VisitHtmlAttribute(HtmlAttributeIntermediateNode node) { if (_currentElementName is null || !allowedElementAttributes.TryGetValue(_currentElementName, out var attributes) || !attributes.Contains(node.AttributeName)) { return; } var valueNode = node.Children.Count == 1 ? node.Children[0] as HtmlAttributeValueIntermediateNode : null; if (TryExpandAssetPath(node, valueNode, node.AttributeName) is not { } expression) { return; } var replacement = new CSharpExpressionAttributeValueIntermediateNode { Prefix = valueNode!.Prefix }; replacement.Children.Add(expression); node.Children[0] = replacement; } // Component parameter attributes: // ComponentAttributeIntermediateNode // -> HtmlContentIntermediateNode (exactly one child) // -> IntermediateToken (Content starts with "~/") public override void VisitComponentAttribute(ComponentAttributeIntermediateNode node) { if (node.BoundAttribute?.Metadata is not PropertyMetadata { AcceptsAssetPath: true } || node.AttributeStructure == AttributeStructure.Minimized) { return; } var contentNode = node.Children.Count == 1 ? node.Children[0] as HtmlContentIntermediateNode : null; if (TryExpandAssetPath(node, contentNode, node.AttributeName) is not { } expression) { return; } var replacement = new CSharpExpressionIntermediateNode(); replacement.Children.Add(expression); node.Children[0] = replacement; } // Expands an opted-in attribute whose value is a single ~/ literal into a synthetic // Assets[@"path"] C# token. Reports RZ10029 when the value mixes a ~/ literal with dynamic // content instead. Returns null (no change) when the value isn't an expandable asset path. private static CSharpIntermediateToken? TryExpandAssetPath(IntermediateNode ownerNode, IntermediateNode? valueContainer, string attributeName) { if (valueContainer is null || GetSingleLiteralToken(valueContainer) is not { } token) { ReportMixedContentDiagnostic(ownerNode, attributeName); return null; } var content = token.Content; if (!content.StartsWith(TildePrefix, StringComparison.Ordinal)) { return null; } var path = content[TildePrefix.Length..]; if (path.Length == 0) { return null; } // Verbatim string literal: backslashes (common in paths) stay literal and only embedded // quotes need doubling. Source is null -- synthetic code with no .razor mapping. var literal = path.Replace("\"", "\"\""); return new CSharpIntermediateToken($"{ComponentsApi.ComponentBase.Assets}[@\"{literal}\"]", source: null); } // Returns the single literal token that makes up the entire value, or null for mixed/dynamic // content (which never expands). private static IntermediateToken? GetSingleLiteralToken(IntermediateNode valueContainer) => valueContainer.Children is [IntermediateToken token] ? token : null; // Only the literal html value portions are inspected, so a ~/ that appears purely inside a // C# expression doesn't trigger the diagnostic. private static void ReportMixedContentDiagnostic(IntermediateNode node, string attributeName) { if (node.Children.Count <= 1) { return; } foreach (var child in node.Children) { if (child is HtmlAttributeValueIntermediateNode or HtmlContentIntermediateNode && GetSingleLiteralToken(child) is { } token && token.Content.StartsWith(TildePrefix, StringComparison.Ordinal)) { node.AddDiagnostic( ComponentDiagnosticFactory.CreateTildePath_MixedContent(token.Source, attributeName)); return; } } } } }