Optimizely CMS and HTML validation message: Trailing slash on void elements has no effect and interacts badly with unquoted attribute values.

When using the W3C Markup Validation Service, some annoying messages pop up because Optimizely CMS adds the trailing slash to self-closing tags, that are not necessary. 

Trailing slash on void elements has no effect and interacts badly with unquoted attribute values.

The relevant methods are:

@Html.CanonicalLink()
@Html.AlternateLinks()

Unfortunately the tag style can not be configured, but we can create our own implementations of the methods above.

public static class HtmlHelperExtensions
{
    public static IHtmlContent CanonicalLinkV2(this IHtmlHelper html)
    {
        return html.CanonicalLinkV2(null, null, null);
    }

    public static IHtmlContent CanonicalLinkV2(this IHtmlHelper html, ContentReference contentLink, string language, string action)
    {
        var services = html.ViewContext.HttpContext.RequestServices;
        var routeHelper = services.GetRequiredService<IContentRouteHelper>();
        var urlResolver = services.GetRequiredService<IUrlResolver>();
        var contentLoader = services.GetRequiredService<IContentLoader>();
        var languageSettings = services.GetRequiredService<IContentLanguageSettingsHandler>();

        contentLink ??= routeHelper.ContentLink;
        language ??= routeHelper.LanguageID;
        action ??= html.ViewContext.HttpContext.GetRouteValue(RoutingConstants.ActionKey) as string;

        if (ContentReference.IsNullOrEmpty(contentLink))
        {
            return HtmlString.Empty;
        }

        // If the content is loaded due to a fallback or replacement settings,
        // then the URLs should be to the original content
        if (language is not null)
        {
            var loaderOptions = new LoaderOptions
        {
            LanguageLoaderOption.FallbackWithMaster(CultureInfo.GetCultureInfo(language))
        };

            if (contentLoader.TryGet(contentLink, loaderOptions, out IContent content) &&
                content is ILocale localizable &&
                languageSettings.MatchLanguageSettings(content, language).FallbackOrReplacement())
            {
                language = localizable.Language.Name;
            }
        }

        var contentUrl = urlResolver.GetUrl(
            contentLink,
            language,
            new VirtualPathArguments
            {
                ForceCanonical = true,
                ForceAbsolute = true,
                Action = action
            });

        if (string.IsNullOrEmpty(contentUrl))
        {
            return HtmlString.Empty;
        }

        return new TagBuilder("link")
        {
            TagRenderMode = TagRenderMode.StartTag,
            Attributes =
        {
            { "rel", "canonical" },
            { "href", contentUrl }
        }
        };
    }

    public static IHtmlContent AlternateLinksV2(this IHtmlHelper html)
    {
        return html.AlternateLinksV21(null, null);
    }

    public static IHtmlContent AlternateLinksV21(this IHtmlHelper html, ContentReference contentLink, string action = null)
    {
        var services = html.ViewContext.HttpContext.RequestServices;
        var routeHelper = services.GetRequiredService<IContentRouteHelper>();
        var urlResolver = services.GetRequiredService<IUrlResolver>();

        contentLink ??= routeHelper.ContentLink;
        action ??= html.ViewContext.HttpContext.GetRouteValue(RoutingConstants.ActionKey) as string;

        List<string> alternateLanguages = GetAlternateLanguages(html, contentLink);
        if (alternateLanguages.Count < 2)
        {
            return HtmlString.Empty;
        }

        VirtualPathArguments virtualPathArguments = new VirtualPathArguments
        {
            ForceCanonical = true,
            ForceAbsolute = true,
            Action = action
        };
        HtmlContentBuilder htmlContentBuilder = new HtmlContentBuilder();
        foreach (string item in alternateLanguages)
        {
            string url = urlResolver.GetUrl(contentLink, item, virtualPathArguments);
            if (!string.IsNullOrEmpty(url))
            {
                TagBuilder tagBuilder = new TagBuilder("link")
                {
                    TagRenderMode = TagRenderMode.StartTag
                };
                tagBuilder.Attributes.Add("rel", "alternate");
                tagBuilder.Attributes.Add("href", UrlEncoder.Encode(url));
                tagBuilder.Attributes.Add("hreflang", item);
                htmlContentBuilder.AppendHtml(tagBuilder);
            }
        }

        return htmlContentBuilder;
    }

    private static List<string> GetAlternateLanguages(this IHtmlHelper html, ContentReference contentLink)
    {
        var services = html.ViewContext.HttpContext.RequestServices;
        var contentRepository = services.GetRequiredService<IContentRepository>();
        var languageBranchRepository = services.GetRequiredService<ILanguageBranchRepository>();
        var publishedStateAssessor = services.GetRequiredService<IPublishedStateAssessor>();

        HashSet<CultureInfo> enabledLanguages = new HashSet<CultureInfo>(from x in languageBranchRepository.ListEnabled()
                                                                            select x.Culture);
        return (from x in (from x in contentRepository.GetLanguageBranches<IContent>(contentLink)
                            where publishedStateAssessor.IsPublished(x) && HasValidLinkType(x)
                            select x).OfType<ILocalizable>()
                where x.Language != null && enabledLanguages.Contains(x.Language)
                select x.Language.Name).ToList().ToList();
    }

    private static bool HasValidLinkType(IContent content)
    {
        if (content is PageData pageData && pageData.LinkType != 0)
        {
            return pageData.LinkType == PageShortcutType.FetchData;
        }

        return true;
    }
}

Simply decompile Optimizely's implementation and make sure to use TagRenderMode.StartTag instead of TagRenderMode.SelfClosing.

Found this post helpful? Help keep this blog ad-free by buying me a coffee! ☕