Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ jobs:
- name: Unit Tests
run: dotnet test --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx"

- name: Validate translation packs
continue-on-error: true
env:
Translation__OutputDirectory: ${{ github.workspace }}/translations
run: dotnet run --project Translator/BTCPayTranslator.csproj --configuration Release --no-build -- validate-packs

- name: Upload test logs
if: always()
uses: actions/upload-artifact@v4
Expand Down
167 changes: 167 additions & 0 deletions Translator.Tests/Services/LanguagePackValidatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,173 @@ await File.WriteAllTextAsync(filePath, $$"""
}
}

[Fact]
public async Task ValidateAsync_FlagsHtmlTagMismatch()
{
var tempDir = CreateTempDirectory();

try
{
var filePath = Path.Combine(tempDir, "hindi.json");
await File.WriteAllTextAsync(filePath, """
{
"<strong>Never</strong> trust anything but <code>id</code>": "केवल <code>id</code> पर भरोसा करें",
"kept-intact": "<code>foo</code> bar <code>baz</code>"
}
""".Replace("<code>foo</code> bar <code>baz</code>",
"<code>foo</code> bar <code>baz</code>"));

// Re-write with a balanced kept-intact entry so only the first entry fails the rule
await File.WriteAllTextAsync(filePath, """
{
"<strong>Never</strong> trust anything but <code>id</code>": "केवल <code>id</code> पर भरोसा करें",
"<code>foo</code>": "<code>foo</code>"
}
""");

var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);

Assert.Equal(2, result.EntriesScanned);
var issue = Assert.Single(result.Issues);
Assert.StartsWith("<strong>Never", issue.Key);
Assert.Contains("Structural HTML tag mismatch", issue.Reason);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public async Task ValidateAsync_IgnoresExampleEmailAngleBrackets()
{
// The HTML-tag check uses a curated allowlist of structural elements
// (strong/em/code/br/p/a/etc.) so localized example data like
// "<email@primer.com>" doesn't trip the rule even though the bare
// HtmlTagRegex would match it.
var tempDir = CreateTempDirectory();

try
{
var filePath = Path.Combine(tempDir, "serbian.json");
await File.WriteAllTextAsync(filePath, """
{
"Firstname Lastname <email@example.com>": "Ime Prezime <email@primer.com>"
}
""");

var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);

Assert.Equal(1, result.EntriesScanned);
Assert.Empty(result.Issues);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public async Task ValidateAsync_FlagsInvalidMaintainerField()
{
var tempDir = CreateTempDirectory();

try
{
var filePath = Path.Combine(tempDir, "bad-maintainer.json");
await File.WriteAllTextAsync(filePath, """
{
"_maintainer": "someone with no pipe or URL",
"hello": "bonjour"
}
""");

var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);

// _maintainer is not counted as a translation entry
Assert.Equal(1, result.EntriesScanned);
var issue = Assert.Single(result.Issues);
Assert.Equal("_maintainer", issue.Key);
Assert.Contains("Invalid _maintainer value", issue.Reason);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public async Task ValidateAsync_AcceptsWellFormedMaintainerField()
{
var tempDir = CreateTempDirectory();

try
{
var filePath = Path.Combine(tempDir, "ok-maintainer.json");
await File.WriteAllTextAsync(filePath, """
{
"_maintainer": "thgO-O|https://github.com/thgO-O",
"hello": "olá"
}
""");

var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);

Assert.Equal(1, result.EntriesScanned);
Assert.Empty(result.Issues);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

[Fact]
public async Task ValidateAsync_RejectsMaintainerWithHttpScheme()
{
var tempDir = CreateTempDirectory();

try
{
var filePath = Path.Combine(tempDir, "http-maintainer.json");
await File.WriteAllTextAsync(filePath, """
{
"_maintainer": "thgO-O|http://github.com/thgO-O"
}
""");

var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);

var issue = Assert.Single(result.Issues);
Assert.Equal("_maintainer", issue.Key);
Assert.Contains("Invalid _maintainer", issue.Reason);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}

private static LanguagePackValidator CreateSut(string outputDirectory)
{
var configuration = new ConfigurationBuilder()
Expand Down
19 changes: 19 additions & 0 deletions Translator/Services/LanguagePackValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ public async Task<ValidationResult> ValidateAsync(bool fix)
{
var key = property.Name;
var value = property.Value?.ToString() ?? string.Empty;

if (key.Equals("_maintainer", StringComparison.Ordinal))
{
if (!TranslationValidationRules.IsValidMaintainerValue(value))
{
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key,
"Invalid _maintainer value (expected '<display name or handle>|<https URL>')"));
}
continue;
}

totalEntries++;

if (TranslationValidationRules.IsSuspiciousMetaResponse(value))
Expand Down Expand Up @@ -112,6 +123,14 @@ public async Task<ValidationResult> ValidateAsync(bool fix)
{
fileChanged |= ApplyFix(property, key, value);
}
continue;
}

if (!TranslationValidationRules.HasMatchingHtmlTags(key, value))
{
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key,
"Structural HTML tag mismatch between source key and translation"));
// Auto-fix is intentionally skipped here. Maintainer needs to re-anchor the markup by hand.
}
}

Expand Down
59 changes: 59 additions & 0 deletions Translator/Services/TranslationValidationRules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ internal static class TranslationValidationRules
private static readonly Regex HtmlTagRegex =
new(@"<[^>]+>", RegexOptions.Compiled);

private static readonly Regex StructuralHtmlTagRegex =
new(@"<\s*/?\s*(strong|em|b|i|u|code|pre|kbd|small|sub|sup|mark|br|p|div|span|a|ul|ol|li|h[1-6]|table|thead|tbody|tr|td|th|abbr|del|ins|q|cite|var|samp)\b[^>]*>",
RegexOptions.Compiled | RegexOptions.IgnoreCase);

private static readonly Regex MaintainerFieldRegex =
new(@"^[^|]+\|https://\S+$", RegexOptions.Compiled);

private static readonly Regex WhitespaceRegex =
new(@"\s+", RegexOptions.Compiled);

Expand Down Expand Up @@ -246,6 +253,38 @@ public static bool HasMatchingPlaceholders(string source, string translation)
return true;
}

/// <summary>
/// Checks that the source and translation use the same multiset of structural HTML tags (case-insensitive).
/// </summary>
public static bool HasMatchingHtmlTags(string source, string translation)
{
var sourceTags = ExtractStructuralTagCounts(source);
var translationTags = ExtractStructuralTagCounts(translation);

if (sourceTags.Count != translationTags.Count)
return false;

foreach (var entry in sourceTags)
{
if (!translationTags.TryGetValue(entry.Key, out var count) || count != entry.Value)
return false;
}

return true;
}

/// <summary>
/// Validates the shape of the _maintainer field that ManifestGenerator expects
/// </summary>
public static bool IsValidMaintainerValue(string value)
{
// if language don't have maintainer
if (string.IsNullOrWhiteSpace(value))
return true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return MaintainerFieldRegex.IsMatch(value.Trim());
}

public static bool IsLikelySentenceFallback(string source, string translation)
{
if (!string.Equals(source, translation, StringComparison.Ordinal))
Expand Down Expand Up @@ -300,4 +339,24 @@ private static Dictionary<string, int> ExtractTokenCounts(string text)

return counts;
}

private static readonly Regex TagNameRegex = new(@"<\s*/?\s*([A-Za-z][A-Za-z0-9]*)", RegexOptions.Compiled);

private static Dictionary<string, int> ExtractStructuralTagCounts(string text)
{
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

foreach (Match match in StructuralHtmlTagRegex.Matches(text))
{
var raw = match.Value;
var nameMatch = TagNameRegex.Match(raw);
if (!nameMatch.Success) continue;
var isClose = raw.TrimStart('<').TrimStart().StartsWith('/');
var key = (isClose ? "/" : string.Empty) + nameMatch.Groups[1].Value.ToLowerInvariant();
if (!counts.TryAdd(key, 1))
counts[key]++;
}

return counts;
}
}
Loading