dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.59k stars 10.06k forks source link

Discussion, HeadContent development #45709

Open Alerinos opened 1 year ago

Alerinos commented 1 year ago

Is there an existing issue for this?

Is your feature request related to a problem? Please describe the problem.

Currently, we have <HeadContent> for head tags.

It lacks default values, let's assume we have a tag like this: <meta name="theme-color" content="#ff6100"> We can put it in _Host.cshtml which will be available on every subpage. What if a subpage wants to modify it? There are currently two ways: 1.) Duplication of the value by HeadContent + _Host.cshtml. bad for SEO 2.) Every subpage should be placed in HeadContent, good for seo but bad for programmer. Code duplication and lots of problems

Alternatively, we can create an intermediate code, unfortunately also bad for the programmer. You will need to remember about the implementation on each page, example:

<HeadContent>
@AppState.Provider
</HeadContent>

I propose to create a default value during definition. That is:

<component type="typeof(HeadOutlet)" render-mode="ServerPrerendered">
<meta name="theme-color" content="#ff6100">
</component>

Then, when compiling the code, razor will detect whether there is already <meta name="theme-color"> in <HeadContent>, if so, it will replace its value, if not, it will do nothing.

Describe the solution you'd like

Let me know what you think about it, I think it's worth discussing this idea and expanding on such a basic thing that has been omitted.

Additional context

No response

javiercn commented 1 year ago

@Alerinos thanks for contacting us.

Then, when compiling the code, razor will detect whether there is already <meta name="theme-color"> in <HeadContent>, if so, it will replace its value, if not, it will do nothing.

What is preventing you from rendering your own component inside the HeadContent component where you keep the common code you want and append additional code from other components?

Seems that what you are looking for is a higher level concept that allows you to decide when to add additional stuff or override it from the page. The HeadContent and HeadOutlet components are not responsible for that, they only care about putting the content in the right place.

There are not good heuristics Blazor can follow either at compile or runtime, these things are an app decision, so we think it is better handled by the developer on their own component.

Alerinos commented 1 year ago

@javiercn I understand the assumption of the components and because of their universality it is difficult to find a good solution here. I think you could enter a default value. We can put there, for example, a page description and other basic meta. If a subpage stands out, we replace <HeadComponent> with these values.

Looking at the component code, I know that we can create a clone ourselves, but shouldn't there also be a default one for the bottom of the page? Let's imagine that there is only one subpage on which there is an editor, it is in JS. I don't want to load it for the whole page for performance reasons, putting the JS code in HeadComponent will delay rendering. Using JSInvoke is possible but not convenient:


    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            var module = await JS.InvokeAsync<IJSObjectReference>("import", "/lib/script.js");
            await module.InvokeVoidAsync(...);

        }

        await base.OnAfterRenderAsync(firstRender);
    }

What do you think of a similar default component for JS scripts?


edit: We are not able to manipulate <meta ..> elements like <PageTitle>?

ghost commented 1 year ago

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

angularsen commented 1 year ago

This would be useful, but we found a workaround that works for us until there is some built-in support. We were able to achieve different title, description, image, etc. for specific pages by updating the <head> during server rendering. This is required for per-page SEO and link previews.

Workaround summary

The key insight is that HeadOutlet only emits the last rendered HeadContent, so a page may override the default HeadContent rendered by _Host.cshtml.

Relevant docs: https://learn.microsoft.com/en-us/aspnet/core/blazor/components/control-head-content?view=aspnetcore-8.0

Example code

_Host.cshtml

<head>
    @* Custom component to add default page metadata, with parameter to optionally override per-page. *@
    <component type="typeof(DefaultMetadata)" render-mode="ServerPrerendered" />

    @* Allow pages to emit custom content in <head> via <PageTitle> and <HeadContent> components. *@
    <component type="typeof(HeadOutlet)" render-mode="ServerPrerendered"  />
</head>

MyPage.razor

@page "/my-page"

<DefaultMetadata Options="@_metadataOptions" />

MyPage.razor.cs

public partial class MyPage
{
    /// <inheritdoc />
    public override async Task SetParametersAsync(ParameterView parameters)
    {
        await base.SetParametersAsync(parameters);

        // Initialize metadata on server-side render.
        await UpdatePageMetadataAsync();
    }

    private async Task UpdatePageMetadataAsync()
    {
        // Get some data asynchronously, will block first render of DOM server-side before passed to browser and hand-off to client side render.
        var someData = await GetSomeDataAsync();

        // Set data, bound to DefaultMetadata component in page.
        _metadataOptions = new DefaultMetadataOptions(
            Title: "My page custom title",
            Description: "My page custom description",
            Image: null,    // Set URL to image for link previews to page.
            ImageAlt: null, // Set alt text for Image.
            ExtraMetaElements: GetExtraMetaElements(someData));

        // Trigger re-render of page and DefaultMetadata component to pick up the new values. Still on first render, server-side.
        await InvokeAsync(StateHasChanged);
    }

    private static MetaElement[] GetExtraMetaElements(SomeData someData)
    {
        // Add custom metadata for this page or update values in the default metadata.
        return new[]
        {
            MetaElement.ByProp("al:ios:url", $"myapp://user/{someData.UserId}"),
            MetaElement.ByProp("al:android:url", $"myapp://user/{someData.UserId}"),
        };
    }
}

DefaultMetadata.razor

Note: The dependency on Toolbelt.Blazor.HeadElement is not essential, you can easily write your own MetaElement type to pass data to the component.

MetaElement source: https://github.com/jsakamoto/Toolbelt.Blazor.HeadElement/blob/master/Abstractions/MetaElement.cs

@using Serilog
@using Toolbelt.Blazor.HeadElement
@* Default metadata to put in HTML head. Add or override metadata with <see cref="Options"/>. Requires <HeadOutlet> in _Host.cshtml. *@
<HeadContent>
    @{
        MetaElement[] metaElements = GetMetaElements();
    }

    @foreach (MetaElement element in metaElements)
    {
        @if (element.Name is { Length: > 0 } name)
        {
            <meta name="@name" content="@element.Content">
        }
        else if (element.Property is { Length: > 0 } property)
        {
            <meta property="@property" content="@element.Content">
        }
        else if (element.HttpEquiv is { Length: > 0 } httpEquiv)
        {
            <meta http-equiv="@httpEquiv" content="@element.Content">
        }
    }
</HeadContent>

DefaultMetadata.razor.cs

using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Toolbelt.Blazor.HeadElement;

namespace MyApp.Pages;

/// <summary>
///     Page metadata options. Add or override the default meta elements in <see cref="DefaultMetadata"/> with this class.
/// </summary>
/// <param name="Title">Page title in metadata, affects preview of links.</param>
/// <param name="Description">Page description in metadata, affects preview of links.</param>
/// <param name="Image">Page image in metadata, affects preview of links.</param>
/// <param name="ImageAlt">Page image alternative text in metadata, affects preview of links.ImageAlt"/>.</param>
/// <param name="ExtraMetaElements">Extra meta elements to add or to override the default meta elements in <see cref="DefaultMetadata"/>.</param>
public record DefaultMetadataOptions(
    string? Title = null,
    string? Description = null,
    string? Image = null,
    string? ImageAlt = null,
    IList<MetaElement>? ExtraMetaElements = null);

/// <summary>
///     Default metadata to put in HTML head.<br />
///     <br />
///     Add or override metadata with <see cref="Options"/>.<br />
///     Requires <see cref="HeadOutlet"/> in _Host.cshtml.
/// </summary>
public partial class DefaultMetadata
{
    /// <summary>
    ///     Options for adding custom page metadata or overriding the default metadata, such as page title, description and image used in link previews.
    /// </summary>
    [Parameter]
    public DefaultMetadataOptions? Options { get; set; }

    [Inject] private NavigationManager NavigationManager { get; set; } = null!;

    protected override void OnParametersSet()
    {
        base.OnParametersSet();
        Url = NavigationManager.Uri;
    }

    private string? Url { get; set; }

    private MetaElement? GetMetaName(string key, string? defaultValue)
    {
        string? value = Options?.ExtraMetaElements?.FirstOrDefault(m => m.Name == key)?.Content ?? defaultValue;
        return value is null ? null : MetaElement.ByName(key, value);
    }

    private MetaElement? GetMetaProp(string key, string? defaultValue)
    {
        string? value = Options?.ExtraMetaElements?.FirstOrDefault(m => m.Property == key)?.Content ?? defaultValue;
        return value is null ? null : MetaElement.ByProp(key, value);
    }

    /// <summary>Gets the default meta elements, optionally overridden by injected by parameters.</summary>
    private MetaElement[] GetMetaElements()
    {
        var title = Options?.Title ?? "My default page title";
        var image = Options?.Image ?? "https://myapp.com/images/logo.png";
        var imageAlt = Options?.ImageAlt ?? "My logo description";
        var description = Options?.Description ?? "My default page description";
        const string siteName = "My app";
        string? url = Url;

        return new[]
        {
            GetMetaName("title", title),
            GetMetaName("description", description),
            GetMetaName("keywords", "my, app"),
            GetMetaName("language", "English"),

            // OpenGraph
            GetMetaProp("og:type", "website"),
            GetMetaProp("og:site_name", siteName),
            GetMetaProp("og:title", title),
            GetMetaProp("og:description", description),
            GetMetaProp("og:image", image),
            GetMetaProp("og:image:alt", imageAlt),
            GetMetaProp("og:url", url),
            // GetMetaProperty("og:image:width", "250"),
            // GetMetaProperty("og:image:height", "250"),

            // Twitter
            // GetMetaProp("twitter:site", ""), // Add twitter account
            GetMetaProp("twitter:card", "summary_large_image"),
            GetMetaProp("twitter:url", url),
            GetMetaProp("twitter:title", title),
            GetMetaProp("twitter:description", description),
            GetMetaProp("twitter:image", image),
            GetMetaProp("twitter:image:alt", imageAlt),
            GetMetaProp("twitter:app:id:iphone", "0000000000"),
            GetMetaProp("twitter:app:name:iphone", "MyApp"),
            GetMetaProp("twitter:app:id:googleplay", "com.my.app"),
            GetMetaProp("twitter:app:name:googleplay", "MyApp"),

            // Facebook / Meta
            GetMetaProp("fb:app_id", "000000000000000"),
            GetMetaProp("al:ios:url", "myapp://"),
            GetMetaProp("al:ios:app_store_id", "0000000000"),
            GetMetaProp("al:ios:app_name", "MyApp"),
            GetMetaProp("al:android:url", "myapp://"),
            GetMetaProp("al:android:package", "com.my.app"),
            GetMetaProp("al:android:app_name", "MyApp"),

            // Apple
            GetMetaProp("apple-itunes-app", $"app-id=0000000000, app-argument={url}"),
        }
        .Where(e => e?.Content.Length > 0)
        .Select(e => e!)
        .ToArray();
    }
}
ghost commented 1 year ago

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.