') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Enhance language tag functionality with normalization, validation, and equality checks by ptr727 · Pull Request #70 · ptr727/LanguageTags · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 53 additions & 8 deletions LanguageTags/LanguageTag.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,16 +320,41 @@ public ExtensionTag()
public override string ToString() =>
Tags.IsEmpty ? string.Empty : $"{Prefix}-{string.Join('-', Tags)}";

/// <summary>
/// Creates a new extension tag with sorted and lowercased tags.
/// </summary>
/// <returns>A normalized copy of this extension tag.</returns>
internal ExtensionTag Normalize() =>
this with
{
Prefix = char.ToLowerInvariant(Prefix),
Tags = [.. Tags.Select(t => t.ToLowerInvariant()).OrderBy(t => t)],
};

/// <summary>
/// Determines whether this instance is equal to another <see cref="ExtensionTag"/>.
/// </summary>
/// <param name="other">The <see cref="ExtensionTag"/> to compare with.</param>
/// <returns>true if the extension tags are equal; otherwise, false.</returns>
public bool Equals(ExtensionTag? other) =>
ReferenceEquals(this, other)
|| (
other is not null
&& char.ToLowerInvariant(Prefix) == char.ToLowerInvariant(other.Prefix)
&& Tags.SequenceEqual(other.Tags, StringComparer.OrdinalIgnoreCase)
);

/// <summary>
/// Returns the hash code for this extension tag.
/// </summary>
/// <returns>A hash code for the current extension tag.</returns>
public override int GetHashCode()
{
HashCode hashCode = new();
hashCode.Add(char.ToLowerInvariant(Prefix));
foreach (string tag in Tags)
{
hashCode.Add(tag, StringComparer.OrdinalIgnoreCase);
}

return hashCode.ToHashCode();
}
}

/// <summary>
Expand DownExpand Up@@ -363,13 +388,33 @@ public PrivateUseTag()
public override string ToString() =>
Tags.IsEmpty ? string.Empty : $"{Prefix}-{string.Join('-', Tags)}";

/// <summary>
/// Creates a new private use tag with sorted and lowercased tags.
/// </summary>
/// <returns>A normalized copy of this private use tag.</returns>
internal PrivateUseTag Normalize() =>
this with
{
Tags = [.. Tags.Select(t => t.ToLowerInvariant()).OrderBy(t => t)],
};

/// <summary>
/// Determines whether this instance is equal to another <see cref="PrivateUseTag"/>.
/// </summary>
/// <param name="other">The <see cref="PrivateUseTag"/> to compare with.</param>
/// <returns>true if the private use tags are equal; otherwise, false.</returns>
public bool Equals(PrivateUseTag? other) =>
ReferenceEquals(this, other)
|| (other is not null && Tags.SequenceEqual(other.Tags, StringComparer.OrdinalIgnoreCase));

/// <summary>
/// Returns the hash code for this private use tag.
/// </summary>
/// <returns>A hash code for the current private use tag.</returns>
public override int GetHashCode()
{
HashCode hashCode = new();
foreach (string tag in Tags)
{
hashCode.Add(tag, StringComparer.OrdinalIgnoreCase);
}

return hashCode.ToHashCode();
}
}
9 changes: 8 additions & 1 deletion LanguageTags/LanguageTagBuilder.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,10 +82,17 @@ public LanguageTagBuilder VariantAddRange(IEnumerable<string> values)
/// <param name="values">The extension values.</param>
/// <returns>The builder instance for method chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="values"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="values"/> is empty.</exception>
public LanguageTagBuilder ExtensionAdd(char prefix, IEnumerable<string> values)
{
ArgumentNullException.ThrowIfNull(values);
_languageTag._extensions.Add(new ExtensionTag(prefix, values));
ImmutableArray<string> tags = [.. values];
if (tags.IsEmpty)
{
throw new ArgumentException("Extension tags cannot be empty.", nameof(values));
}

_languageTag._extensions.Add(new ExtensionTag(prefix, tags));
return this;
}

Expand Down
7 changes: 5 additions & 2 deletions LanguageTags/LanguageTagParser.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,7 +290,9 @@ private static bool ValidateExtensionPrefix(string tag) =>

private static bool ValidateExtension(string tag) =>
// 2 - 8 chars
!string.IsNullOrEmpty(tag) && tag.Length is >= 2 and <= 8;
!string.IsNullOrWhiteSpace(tag)
&& tag.Length is >= 2 and <= 8
&& !tag.Any(char.IsWhiteSpace);

private bool ParseExtension()
{
Expand DownExpand Up@@ -788,7 +790,8 @@ internal static bool Validate(LanguageTag languageTag)
}
if (
languageTag._extensions.Any(extension =>
!ValidateExtensionPrefix(extension.Prefix.ToString())
extension.Tags.IsEmpty
|| !ValidateExtensionPrefix(extension.Prefix.ToString())
|| extension.Tags.Any(tag => !ValidateExtension(tag))
)
)
Expand Down
14 changes: 10 additions & 4 deletions LanguageTagsCreate/HttpClientFactory.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,8 +28,11 @@ private static ResilienceHandler CreateResilienceHandler() =>
MaxDelay = TimeSpan.FromSeconds(30),
ShouldHandle = args =>
ValueTask.FromResult(
args.Outcome.Result != null
&& !args.Outcome.Result.IsSuccessStatusCode
args.Outcome.Exception != null
|| (
args.Outcome.Result != null
&& !args.Outcome.Result.IsSuccessStatusCode
)
),
}
)
Expand All@@ -42,8 +45,11 @@ private static ResilienceHandler CreateResilienceHandler() =>
BreakDuration = TimeSpan.FromSeconds(30),
ShouldHandle = args =>
ValueTask.FromResult(
args.Outcome.Result != null
&& !args.Outcome.Result.IsSuccessStatusCode
args.Outcome.Exception != null
|| (
args.Outcome.Result != null
&& !args.Outcome.Result.IsSuccessStatusCode
)
),
}
)
Expand Down
4 changes: 0 additions & 4 deletions LanguageTagsCreate/Program.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,10 +8,6 @@ CancellationToken cancellationToken
private const string DataDirectory = "LanguageData";
private const string CodeDirectory = "LanguageTags";

internal CommandLine.Options GetCommandLineOptions() => commandLineOptions;

internal CancellationToken GetCancellationToken() => cancellationToken;

internal static async Task<int> Main(string[] args)
{
// Parse commandline
Expand Down
14 changes: 14 additions & 0 deletions LanguageTagsTests/LanguageTagBuilderTests.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,6 +125,10 @@ public void Build_Fail()
// Extension prefix 1 char, not x
languageTag = new LanguageTagBuilder().Language("en").ExtensionAdd('x', ["abcd"]).Build();
_ = languageTag.Validate().Should().BeFalse();

// Extension tags must not be whitespace
languageTag = new LanguageTagBuilder().Language("en").ExtensionAdd('a', [" "]).Build();
_ = languageTag.Validate().Should().BeFalse();
}

[Fact]
Expand DownExpand Up@@ -161,6 +165,16 @@ public void ExtensionAdd_ThrowsOnNull()
.NotBeNull();
}

[Fact]
public void ExtensionAdd_ThrowsOnEmpty()
{
LanguageTagBuilder builder = new();
_ = Assert
.Throws<ArgumentException>(() => builder.ExtensionAdd('u', []))
.Should()
.NotBeNull();
}

[Fact]
public void PrivateUseAddRange_ThrowsOnNull()
{
Expand Down
1 change: 1 addition & 0 deletions LanguageTagsTests/LanguageTagParserTests.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -95,6 +95,7 @@ public void Normalize_Sort_Pass(string tag, string parsed)
[InlineData("en-gb-abcde-abcde")] // Variant repeats
[InlineData("en-gb-a-abcd-a-abcde")] // Extension prefix repeats
[InlineData("en-gb-a-abcd-abcd")] // Extension tag repeats
[InlineData("en-a- ")] // Extension tag whitespace
[InlineData("en-gb-x-abcd-x-abcd")] // Private prefix repeats
[InlineData("en-gb-x-abcd-abcd")] // Private tag repeats
public void Parse_Fail(string tag) => _ = new LanguageTagParser().Parse(tag).Should().BeNull();
Expand Down
26 changes: 12 additions & 14 deletions LanguageTagsTests/LanguageTagTests.cs
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
using System.Collections.Immutable;
using System.Linq;

namespace ptr727.LanguageTags.Tests;

Expand DownExpand Up@@ -564,16 +563,15 @@ public void ExtensionTag_EnumerableConstructor_CreatesTag()
public void ExtensionTag_RecordEquality_WorksCorrectly()
{
ExtensionTag ext1 = new('u', ["ca", "buddhist"]);
ExtensionTag ext2 = new('u', ["ca", "buddhist"]);
ExtensionTag ext2 = new('U', ["CA", "BUDDHIST"]);
ExtensionTag ext3 = new('t', ["ca", "buddhist"]);

// Records with ImmutableArray need element-wise comparison
_ = ext1.Prefix.Should().Be(ext2.Prefix);
_ = ext1.Tags.SequenceEqual(ext2.Tags).Should().BeTrue();
_ = ext1.ToString().Should().Be(ext2.ToString());
_ = ext1.Equals(ext2).Should().BeTrue();
_ = (ext1 == ext2).Should().BeTrue();
_ = ext1.GetHashCode().Should().Be(ext2.GetHashCode());

_ = ext1.Prefix.Should().NotBe(ext3.Prefix);
_ = ext1.ToString().Should().NotBe(ext3.ToString());
_ = ext1.Equals(ext3).Should().BeFalse();
_ = (ext1 != ext3).Should().BeTrue();
}

[Fact]
Expand All@@ -599,15 +597,15 @@ public void PrivateUseTag_EnumerableConstructor_CreatesTag()
public void PrivateUseTag_RecordEquality_WorksCorrectly()
{
PrivateUseTag priv1 = new(["private1", "private2"]);
PrivateUseTag priv2 = new(["private1", "private2"]);
PrivateUseTag priv2 = new(["PRIVATE1", "PRIVATE2"]);
PrivateUseTag priv3 = new(["other"]);

// Records with ImmutableArray need element-wise comparison
_ = priv1.Tags.SequenceEqual(priv2.Tags).Should().BeTrue();
_ = priv1.ToString().Should().Be(priv2.ToString());
_ = priv1.Equals(priv2).Should().BeTrue();
_ = (priv1 == priv2).Should().BeTrue();
_ = priv1.GetHashCode().Should().Be(priv2.GetHashCode());

_ = priv1.Tags.SequenceEqual(priv3.Tags).Should().BeFalse();
_ = priv1.ToString().Should().NotBe(priv3.ToString());
_ = priv1.Equals(priv3).Should().BeFalse();
_ = (priv1 != priv3).Should().BeTrue();
}

[Fact]
Expand Down