Skip to content

Commit

Permalink
Add an appendVersion option to GenerateScriptImports and GenerateStyl…
Browse files Browse the repository at this point in the history
…eImports
  • Loading branch information
gunndabad committed May 10, 2024
1 parent 843e47d commit da1cf08
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 18 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

#### Page template

New overloads of `GenerateScriptImports` and `GenerateStyleImports` have been added that accept an `appendVersion` parameter.
This appends a query string with a hash of the file's contents so that content changes following upgrades are seen by end users.

## 2.0.0

Targets GOV.UK Frontend v5.1.0.
Expand Down
61 changes: 54 additions & 7 deletions src/GovUk.Frontend.AspNetCore/HtmlHelperExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,74 @@ namespace GovUk.Frontend.AspNetCore;
/// </summary>
public static class HtmlHelperExtensions
{
/// <inheritdoc cref="PageTemplateHelper.GenerateJsEnabledScript(string?)"/>
/// <summary>
/// Gets the CSP hash for the script that adds a <c>js-enabled</c> CSS class.
/// </summary>
/// <param name="htmlHelper">The <see cref="IHtmlHelper"/>.</param>
/// <returns>A hash to be included in your site's <c>Content-Security-Policy</c> header within the <c>script-src</c> directive.</returns>
public static string GetJsEnabledScriptCspHash(this IHtmlHelper htmlHelper)
{
ArgumentNullException.ThrowIfNull(htmlHelper);
var pageTemplateHelper = htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService<PageTemplateHelper>();
return pageTemplateHelper.GetJsEnabledScriptCspHash();
}

/// <summary>
/// Generates the script that adds a <c>js-enabled</c> CSS class.
/// </summary>
/// <remarks>
/// <para>
/// The contents of this property should be inserted at the beginning of the <c>body</c> tag.
/// </para>
/// <para>
/// Use the <see cref="GetJsEnabledScriptCspHash"/> method to retrieve a CSP hash if you are not specifying <paramref name="cspNonce"/>.
/// </para>
/// </remarks>
/// <param name="htmlHelper">The <see cref="IHtmlHelper"/>.</param>
/// <param name="cspNonce">The CSP nonce attribute to be added to the generated <c>script</c> tag.</param>
/// <returns><see cref="IHtmlContent"/> containing the <c>script</c> tag.</returns>
public static IHtmlContent GovUkFrontendJsEnabledScript(this IHtmlHelper htmlHelper, string? cspNonce = null)
{
ArgumentNullException.ThrowIfNull(htmlHelper);
var pageTemplateHelper = htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService<PageTemplateHelper>();
return pageTemplateHelper.GenerateJsEnabledScript(cspNonce);
}

/// <inheritdoc cref="PageTemplateHelper.GenerateScriptImports(string?)"/>
public static IHtmlContent GovUkFrontendScriptImports(this IHtmlHelper htmlHelper, string? cspNonce = null)
/// <summary>
/// Generates the script that adds a <c>js-enabled</c> CSS class.
/// </summary>
/// <remarks>
/// <para>
/// The contents of this property should be inserted at the beginning of the <c>body</c> tag.
/// </para>
/// <para>
/// Use the <see cref="GetJsEnabledScriptCspHash"/> method to retrieve a CSP hash if you are not specifying <paramref name="cspNonce"/>.
/// </para>
/// </remarks>
/// <param name="htmlHelper">The <see cref="IHtmlHelper"/>.</param>
/// <param name="cspNonce">The CSP nonce attribute to be added to the generated <c>script</c> tag.</param>
/// <param name="appendVersion">Whether the file version should be appended to the <c>src</c> attribute.</param>
/// <returns><see cref="IHtmlContent"/> containing the <c>script</c> tag.</returns>
public static IHtmlContent GovUkFrontendScriptImports(this IHtmlHelper htmlHelper, string? cspNonce = null, bool appendVersion = true)
{
ArgumentNullException.ThrowIfNull(htmlHelper);
var pageTemplateHelper = htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService<PageTemplateHelper>();
return pageTemplateHelper.GenerateScriptImports(cspNonce);
return pageTemplateHelper.GenerateScriptImports(cspNonce, appendVersion);
}

/// <inheritdoc cref="PageTemplateHelper.GenerateStyleImports"/>
public static IHtmlContent GovUkFrontendStyleImports(this IHtmlHelper htmlHelper)
/// <summary>
/// Generates the HTML that imports the GOV.UK Frontend library styles.
/// </summary>
/// <remarks>
/// The contents of this property should be inserted in the <c>head</c> tag.
/// </remarks>
/// <param name="htmlHelper">The <see cref="IHtmlHelper"/>.</param>
/// <param name="appendVersion">Whether the file version should be appended to the <c>href</c> attribute.</param>
/// <returns><see cref="IHtmlContent"/> containing the <c>link</c> tags.</returns>
public static IHtmlContent GovUkFrontendStyleImports(this IHtmlHelper htmlHelper, bool appendVersion = true)
{
ArgumentNullException.ThrowIfNull(htmlHelper);
var pageTemplateHelper = htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService<PageTemplateHelper>();
return pageTemplateHelper.GenerateStyleImports();
return pageTemplateHelper.GenerateStyleImports(appendVersion);
}
}
74 changes: 65 additions & 9 deletions src/GovUk.Frontend.AspNetCore/PageTemplateHelper.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;

namespace GovUk.Frontend.AspNetCore;
Expand All @@ -15,6 +18,9 @@ namespace GovUk.Frontend.AspNetCore;
public class PageTemplateHelper
{
internal const string JsEnabledScript = "document.body.className += ' js-enabled' + ('noModule' in HTMLScriptElement.prototype ? ' govuk-frontend-supported' : '');";
private const string VersionQueryParameterName = "v";

private static readonly ConcurrentDictionary<string, string> _embeddedResourceFileVersionCache = new();

private readonly IOptions<GovUkFrontendAspNetCoreOptions> _optionsAccessor;

Expand Down Expand Up @@ -63,19 +69,35 @@ public IHtmlContent GenerateJsEnabledScript(string? cspNonce = null)
}

/// <summary>
/// Generates the HTML that imports the GOV.UK Frontend library script and initializes it.
/// Generates the script that adds a <c>js-enabled</c> CSS class.
/// </summary>
/// <remarks>
/// <para>
/// The contents of this property should be inserted at the end of the <c>body</c> tag.
/// The contents of this property should be inserted at the beginning of the <c>body</c> tag.
/// </para>
/// <para>
/// Use the <see cref="GetInitScriptCspHash()"/> method to retrieve a CSP hash if you are not specifying <paramref name="cspNonce"/>.
/// Use the <see cref="GetJsEnabledScriptCspHash"/> method to retrieve a CSP hash if you are not specifying <paramref name="cspNonce"/>.
/// </para>
/// </remarks>
/// <param name="cspNonce">The CSP nonce attribute to be added to the generated initialization <c>script</c> tag.</param>
/// <returns><see cref="IHtmlContent"/> containing the <c>script</c> tags.</returns>
public IHtmlContent GenerateScriptImports(string? cspNonce = null)
/// <param name="cspNonce">The CSP nonce attribute to be added to the generated <c>script</c> tag.</param>
/// <returns><see cref="IHtmlContent"/> containing the <c>script</c> tag.</returns>
public IHtmlContent GenerateScriptImports(string? cspNonce = null) => GenerateScriptImports(cspNonce, appendVersion: false);

/// <summary>
/// Generates the script that adds a <c>js-enabled</c> CSS class.
/// </summary>
/// <remarks>
/// <para>
/// The contents of this property should be inserted at the beginning of the <c>body</c> tag.
/// </para>
/// <para>
/// Use the <see cref="GetJsEnabledScriptCspHash"/> method to retrieve a CSP hash if you are not specifying <paramref name="cspNonce"/>.
/// </para>
/// </remarks>
/// <param name="cspNonce">The CSP nonce attribute to be added to the generated <c>script</c> tag.</param>
/// <param name="appendVersion">Whether the file version should be appended to the <c>src</c> attribute.</param>
/// <returns><see cref="IHtmlContent"/> containing the <c>script</c> tag.</returns>
public IHtmlContent GenerateScriptImports(string? cspNonce = null, bool appendVersion = false)
{
var compiledContentPath = _optionsAccessor.Value.CompiledContentPath;
if (compiledContentPath is null)
Expand All @@ -92,9 +114,16 @@ public IHtmlContent GenerateScriptImports(string? cspNonce = null)

TagBuilder GenerateImportScript()
{
var src = $"{compiledContentPath}/all.min.js";
if (appendVersion)
{
var version = _embeddedResourceFileVersionCache.GetOrAdd("Content/Compiled/all.min.js", path => GetEmbeddedResourceVersion(path));
src = QueryHelpers.AddQueryString(src, VersionQueryParameterName, version);
}

var tagBuilder = new TagBuilder("script");
tagBuilder.MergeAttribute("type", "module");
tagBuilder.MergeAttribute("src", $"{compiledContentPath}/all.min.js");
tagBuilder.MergeAttribute("src", src);
return tagBuilder;
}

Expand All @@ -121,15 +150,32 @@ TagBuilder GenerateInitScript()
/// The contents of this property should be inserted in the <c>head</c> tag.
/// </remarks>
/// <returns><see cref="IHtmlContent"/> containing the <c>link</c> tags.</returns>
public IHtmlContent GenerateStyleImports()
public IHtmlContent GenerateStyleImports() => GenerateStyleImports(appendVersion: false);

/// <summary>
/// Generates the HTML that imports the GOV.UK Frontend library styles.
/// </summary>
/// <remarks>
/// The contents of this property should be inserted in the <c>head</c> tag.
/// </remarks>
/// <param name="appendVersion">Whether the file version should be appended to the <c>src</c> attribute.</param>
/// <returns><see cref="IHtmlContent"/> containing the <c>link</c> tags.</returns>
public IHtmlContent GenerateStyleImports(bool appendVersion)
{
var compiledContentPath = _optionsAccessor.Value.CompiledContentPath;
if (compiledContentPath is null)
{
throw new InvalidOperationException($"Cannot generate style imports when {nameof(GovUkFrontendAspNetCoreOptions.CompiledContentPath)} is null.");
}

return new HtmlString($"<link href=\"{compiledContentPath}/all.min.css\" rel=\"stylesheet\">");
var href = $"{compiledContentPath}/all.min.css";
if (appendVersion)
{
var version = _embeddedResourceFileVersionCache.GetOrAdd("Content/Compiled/all.min.css", path => GetEmbeddedResourceVersion(path));
href = QueryHelpers.AddQueryString(href, VersionQueryParameterName, version);
}

return new HtmlString($"<link href=\"{href}\" rel=\"stylesheet\">");
}

/// <summary>
Expand Down Expand Up @@ -174,4 +220,14 @@ private static string GenerateCspHash(string value)
var hash = algo.ComputeHash(Encoding.UTF8.GetBytes(value));
return $"'sha256-{Convert.ToBase64String(hash)}'";
}

private static string GetEmbeddedResourceVersion(string path)
{
using var resourceStream = typeof(PageTemplateHelper).Assembly.GetManifestResourceStream($"{path}") ??
throw new ArgumentException($"Could not find resource: '{path}'.", nameof(path));
using var ms = new MemoryStream();
resourceStream.CopyTo(ms);
var hash = SHA256.HashData(ms.ToArray());
return WebEncoders.Base64UrlEncode(hash);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
}
else
{
@Html.GovUkFrontendStyleImports()
@Html.GovUkFrontendStyleImports(appendVersion: true)
}

<meta property="og:image" content="@(ogImage)">
Expand Down Expand Up @@ -83,7 +83,7 @@
}
else
{
@Html.GovUkFrontendScriptImports(cspNonce)
@Html.GovUkFrontendScriptImports(cspNonce, appendVersion: true)
}
</body>

Expand Down

0 comments on commit da1cf08

Please sign in to comment.