< Summary

Information
Class: Allyaria.Theming.Values.AllyariaImageValue
Assembly: Allyaria.Theming
File(s): /home/runner/work/allyaria/allyaria/src/Allyaria.Theming/Values/AllyariaImageValue.cs
Line coverage
100%
Covered lines: 66
Uncovered lines: 0
Coverable lines: 66
Total lines: 259
Line coverage: 100%
Branch coverage
100%
Covered branches: 36
Total branches: 36
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
EnsureAllowedAbsoluteSchemeIfPresent(...)100%1010100%
EnsureNoDangerousSchemes(...)100%44100%
EscapeForCssUrl(...)100%11100%
ExtractFirstUrlInnerValueOrDefault(...)100%22100%
Normalize(...)100%22100%
Parse(...)100%11100%
ToCssBackground(...)100%44100%
ToCssVarsBackground(...)100%44100%
TryParse(...)100%11100%
UnwrapOptionalQuotes(...)100%1010100%
op_Implicit(...)100%11100%
op_Implicit(...)100%11100%

File(s)

/home/runner/work/allyaria/allyaria/src/Allyaria.Theming/Values/AllyariaImageValue.cs

#LineLine coverage
 1using Allyaria.Theming.Contracts;
 2using Allyaria.Theming.Helpers;
 3using System.Diagnostics.CodeAnalysis;
 4using System.Text.RegularExpressions;
 5
 6namespace Allyaria.Theming.Values;
 7
 8/// <summary>
 9/// Represents a strongly-typed CSS image value used by Allyaria theming. The underlying string is normalized to a
 10/// canonical CSS <c>url("…")</c> token. If the input contains a pre-formed <c>url(...)</c> (even within a longer
 11/// declaration such as <c>linear-gradient(...), url(foo) no-repeat</c>), only the first <c>url(...)</c> is extracted an
 12/// used; all other content is discarded. Otherwise, the raw input is validated, unwrapped (if quoted), escaped, and
 13/// wrapped as <c>url("…")</c>.
 14/// </summary>
 15public sealed class AllyariaImageValue : ValueBase
 16{
 17    /// <summary>Initializes a new instance of the <see cref="AllyariaImageValue" /> class.</summary>
 18    /// <param name="value">
 19    /// The raw image reference to normalize (absolute/relative URL, data/blob URI, or any string containing a <c>url(..
 20    /// ).
 21    /// </param>
 22    /// <exception cref="ArgumentException">
 23    /// Thrown when <paramref name="value" /> is <c>null</c>, empty, whitespace, contains disallowed control characters,
 24    /// resolves to an unsupported URI scheme (e.g., <c>javascript:</c>, <c>vbscript:</c>).
 25    /// </exception>
 26    public AllyariaImageValue(string value)
 11027        : base(Normalize(value)) { }
 28
 29    /// <summary>
 30    /// If <paramref name="value" /> parses as an absolute <see cref="Uri" />, ensures its scheme is allowed. Allowed sc
 31    /// are <c>http</c>, <c>https</c>, <c>data</c>, and <c>blob</c>. Relative values are permitted.
 32    /// </summary>
 33    /// <param name="value">The value to check.</param>
 34    private static void EnsureAllowedAbsoluteSchemeIfPresent(string value)
 35    {
 5236        if (Uri.TryCreate(value, UriKind.Absolute, out var uri))
 37        {
 1238            var allowed = uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ||
 1239                uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ||
 1240                uri.Scheme.Equals("data", StringComparison.OrdinalIgnoreCase) ||
 1241                uri.Scheme.Equals("blob", StringComparison.OrdinalIgnoreCase);
 42
 1243            if (!allowed)
 44            {
 445                throw new ArgumentException($"Unsupported URI scheme '{uri.Scheme}'.", nameof(value));
 46            }
 47        }
 4848    }
 49
 50    /// <summary>
 51    /// Throws <see cref="ArgumentException" /> when <paramref name="value" /> starts with a dangerous scheme such as
 52    /// <c>javascript:</c> or <c>vbscript:</c> (case-insensitive).
 53    /// </summary>
 54    /// <param name="value">The value to inspect.</param>
 55    private static void EnsureNoDangerousSchemes(string value)
 56    {
 5857        if (value.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
 5858            value.StartsWith("vbscript:", StringComparison.OrdinalIgnoreCase))
 59        {
 660            throw new ArgumentException("Unsupported URI scheme for CSS image value.", nameof(value));
 61        }
 5262    }
 63
 64    /// <summary>
 65    /// Escapes a string for safe inclusion inside a CSS <c>url("…")</c> token. Currently escapes backslashes and double
 66    /// quotes. Parentheses and spaces are safe inside quotes and preserved as-is.
 67    /// </summary>
 68    /// <param name="value">The unquoted, validated value to escape.</param>
 69    /// <returns>The escaped string.</returns>
 4870    private static string EscapeForCssUrl(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
 71
 72    /// <summary>
 73    /// Attempts to extract the inner content of the first CSS <c>url(...)</c> token found in <paramref name="input" />.
 74    /// returned string excludes the surrounding <c>url(</c> and <c>)</c> and preserves any quotes inside the token.
 75    /// </summary>
 76    /// <param name="input">A CSS string that may contain one or more <c>url(...)</c> tokens.</param>
 77    /// <returns>The inner value of the first <c>url(...)</c> if found; otherwise <c>null</c>.</returns>
 78    private static string? ExtractFirstUrlInnerValueOrDefault(string input)
 79    {
 5880        var match = Regex.Match(
 5881            input,
 5882            @"url\(\s*(?<inner>.*?)\s*\)",
 5883            RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline
 5884        );
 85
 5886        return match.Success
 5887            ? match.Groups["inner"].Value
 5888            : null;
 89    }
 90
 91    /// <summary>Normalizes an input string for use as a canonical CSS image <c>url("…")</c> token.</summary>
 92    /// <param name="value">
 93    /// Raw image reference. May be a bare path/URI or a larger CSS value containing one or more <c>url(...)</c> tokens.
 94    /// </param>
 95    /// <returns>A trimmed, validated, and escaped string in the form <c>url("…")</c>.</returns>
 96    /// <exception cref="ArgumentException">
 97    /// Thrown when <paramref name="value" /> is <c>null</c>, empty, whitespace, contains disallowed control characters,
 98    /// resolves to an unsupported URI scheme (e.g., <c>javascript:</c>, <c>vbscript:</c>).
 99    /// </exception>
 100    private static string Normalize(string value)
 101    {
 62102        var trimmed = ValidateInput(value);
 58103        var extractedOrOriginal = ExtractFirstUrlInnerValueOrDefault(trimmed) ?? trimmed;
 58104        var unquoted = UnwrapOptionalQuotes(extractedOrOriginal);
 105
 58106        EnsureNoDangerousSchemes(unquoted);
 52107        EnsureAllowedAbsoluteSchemeIfPresent(unquoted);
 108
 48109        var escaped = EscapeForCssUrl(unquoted);
 110
 48111        return $"url(\"{escaped}\")";
 112    }
 113
 114    /// <summary>Parses the specified string into an <see cref="AllyariaImageValue" />.</summary>
 115    /// <param name="value">The input string to parse.</param>
 116    /// <returns>A new <see cref="AllyariaImageValue" /> containing the normalized <paramref name="value" />.</returns>
 117    /// <exception cref="ArgumentException">
 118    /// Thrown when <paramref name="value" /> is invalid; see
 119    /// <see cref="Normalize(string)" />.
 120    /// </exception>
 2121    public static AllyariaImageValue Parse(string value) => new(value);
 122
 123    /// <summary>Builds CSS declarations for a background image with a contrast-enhancing overlay.</summary>
 124    /// <param name="backgroundColor">
 125    /// The known page or container background color beneath the image. Used to determine whether the overlay should be 
 126    /// light for better contrast.
 127    /// </param>
 128    /// <param name="stretch">
 129    /// If <c>true</c>, returns a set of individual CSS declarations—<c>background-image</c>, <c>background-position</c>
 130    /// <c>background-repeat</c>, and <c>background-size</c>—so that the image covers the area and is centered with no r
 131    /// If <c>false</c>, returns only a single <c>background-image</c> declaration (no extra sizing or positioning).
 132    /// </param>
 133    /// <returns>
 134    /// A CSS string: either just a <c>background-image</c> property (when <paramref name="stretch" /> is <c>false</c>),
 135    /// multiple declarations joined together to achieve a centered, cover-filling background (when <paramref name="stre
 136    /// is <c>true</c>).
 137    /// </returns>
 138    /// <remarks>
 139    /// The overlay increases contrast: – For light backgrounds (relative luminance ≥ 0.5), it uses <c>rgba(0,0,0,0.5)</
 140    /// For dark backgrounds, it uses <c>rgba(255,255,255,0.5)</c>. This helps keep the background image legible regardl
 141    /// the page’s base color.
 142    /// </remarks>
 143    public string ToCssBackground(AllyariaColorValue backgroundColor, bool stretch = true)
 144    {
 12145        var lum = ColorHelper.RelativeLuminance(backgroundColor);
 146
 12147        var overlay = lum >= 0.5
 12148            ? "rgba(0, 0, 0, 0.5)"
 12149            : "rgba(255, 255, 255, 0.5)";
 150
 12151        var image = $"background-image:linear-gradient({overlay},{overlay}),{Value};";
 12152        var position = "background-position:center;";
 12153        var repeat = "background-repeat:no-repeat;";
 12154        var size = "background-size:cover";
 155
 12156        return stretch
 12157            ? $"{image}{position}{repeat}{size}"
 12158            : image;
 159    }
 160
 161    /// <summary>Builds CSS variables for a background image with a contrast-enhancing overlay.</summary>
 162    /// <param name="prefix">An string used to namespace the CSS variables.</param>
 163    /// <param name="backgroundColor">
 164    /// The known page or container background color beneath the image. Used to determine whether the overlay should be 
 165    /// light for better contrast.
 166    /// </param>
 167    /// <param name="stretch">
 168    /// If <c>true</c>, returns a set of individual CSS variables—<c>background-image</c>, <c>background-position</c>,
 169    /// <c>background-repeat</c>, and <c>background-size</c>—so that the image covers the area and is centered with no r
 170    /// If <c>false</c>, returns only a single <c>background-image</c> declaration (no extra sizing or positioning).
 171    /// </param>
 172    /// <returns>
 173    /// A CSS string: either just a <c>background-image</c> variable (when <paramref name="stretch" /> is <c>false</c>),
 174    /// multiple variables joined together to achieve a centered, cover-filling background (when <paramref name="stretch
 175    /// <c>true</c>).
 176    /// </returns>
 177    /// <remarks>
 178    /// The overlay increases contrast: – For light backgrounds (relative luminance ≥ 0.5), it uses <c>rgba(0,0,0,0.5)</
 179    /// For dark backgrounds, it uses <c>rgba(255,255,255,0.5)</c>. This helps keep the background image legible regardl
 180    /// the page’s base color.
 181    /// </remarks>
 182    public string ToCssVarsBackground(string prefix, AllyariaColorValue backgroundColor, bool stretch = true)
 183    {
 8184        var lum = ColorHelper.RelativeLuminance(backgroundColor);
 185
 8186        var overlay = lum >= 0.5
 8187            ? "rgba(0, 0, 0, 0.5)"
 8188            : "rgba(255, 255, 255, 0.5)";
 189
 8190        var image = $"{prefix}background-image:linear-gradient({overlay},{overlay}),{Value};";
 8191        var position = $"{prefix}background-position:center;";
 8192        var repeat = $"{prefix}background-repeat:no-repeat;";
 8193        var size = $"{prefix}background-size:cover";
 194
 8195        return stretch
 8196            ? $"{image}{position}{repeat}{size}"
 8197            : image;
 198    }
 199
 200    /// <summary>Attempts to parse the specified string into an <see cref="AllyariaImageValue" />.</summary>
 201    /// <param name="value">The input string to parse.</param>
 202    /// <param name="result">
 203    /// When this method returns, contains the parsed <see cref="AllyariaImageValue" /> if parsing succeeded; otherwise
 204    /// <c>null</c>.
 205    /// </param>
 206    /// <returns><c>true</c> if parsing succeeded; otherwise, <c>false</c>.</returns>
 207    public static bool TryParse(string value, [NotNullWhen(true)] out AllyariaImageValue? result)
 208    {
 209        try
 210        {
 8211            result = new AllyariaImageValue(value);
 212
 2213            return true;
 214        }
 6215        catch
 216        {
 6217            result = null;
 218
 6219            return false;
 220        }
 8221    }
 222
 223    /// <summary>
 224    /// Removes a single pair of matching surrounding quotes (single or double) from <paramref name="s" />, if present. 
 225    /// the original string if not quoted or if quotes do not match.
 226    /// </summary>
 227    /// <param name="s">The input string to unwrap.</param>
 228    /// <returns>The unwrapped string, or <paramref name="s" /> if no wrapping quotes are present.</returns>
 229    private static string UnwrapOptionalQuotes(string s)
 230    {
 58231        if (s.Length >= 2)
 232        {
 58233            var first = s[0];
 58234            var last = s[^1];
 235
 58236            if ((first is '"' && last is '"') || (first is '\'' && last is '\''))
 237            {
 4238                return s.Substring(1, s.Length - 2);
 239            }
 240        }
 241
 54242        return s;
 243    }
 244
 245    /// <summary>Defines an implicit conversion from <see cref="string" /> to <see cref="AllyariaImageValue" />.</summar
 246    /// <param name="value">The string value to convert.</param>
 247    /// <returns>A new <see cref="AllyariaImageValue" /> containing the normalized <paramref name="value" />.</returns>
 248    /// <exception cref="ArgumentException">
 249    /// Thrown when <paramref name="value" /> is invalid; see
 250    /// <see cref="Normalize(string)" />.
 251    /// </exception>
 14252    public static implicit operator AllyariaImageValue(string value) => new(value);
 253
 254    /// <summary>Defines an implicit conversion from <see cref="AllyariaImageValue" /> to <see cref="string" />.</summar
 255    /// <param name="value">The <see cref="AllyariaImageValue" /> instance.</param>
 256    /// <returns>The underlying normalized string value (a canonical CSS <c>url("…")</c> token).</returns>
 257    /// <exception cref="ArgumentNullException">Thrown when <paramref name="value" /> is <c>null</c>.</exception>
 6258    public static implicit operator string(AllyariaImageValue value) => value.Value;
 259}