From 5d13b1a13253de24ca30ae3aa8d6060cf165a88d Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 21 Oct 2026 10:10:59 +0200 Subject: [PATCH 1/5] Prepare the website for twenty more languages The site generator already treated a language as a file per page and a key per word. This adds what a larger set needs and keeps the rule that a half-finished language fails the build. - A language registry in site.json: tag, og:locale, the language's own name, the short code the switch shows, direction and slug policy. A language can be in the registry and not published. - The language switch is a disclosure listing every language by its own name, with no script. - Slugs are either the language's own or the default language's, so the alphabets that cannot be an ASCII address are not transliterated into one. - {{url:ID}} resolves another page's address in the language of the page that writes it. - A translation is held against its original: same block structure, same links to the same pages, same commands (comments and angle-bracket placeholders excepted), same tokens. - A translation records which English it follows. An older one is listed, and fails the build only with --require-fresh. --stamp CODE records it, and a missing stamp is always a problem. - Right to left: logical properties instead of left and right, code isolated from the page direction, no letter spacing in cursive or CJK scripts, a font stack and line height per script. - The Polish code samples go back to the English commands so one check covers every language, and the question about the program's own language is now in the English source too. Co-Authored-By: Claude Sonnet 5.5 --- site/Bws.Site/Build.cs | 70 ++++- site/Bws.Site/Freshness.cs | 251 ++++++++++++++++++ site/Bws.Site/Pages.cs | 59 ++-- site/Bws.Site/Parity.cs | 248 +++++++++++++++++ site/Bws.Site/Program.cs | 53 +++- site/Bws.Site/Renderer.cs | 63 +++-- site/Bws.Site/SiteConfig.cs | 107 +++++++- site/Bws.Site/Tokens.cs | 27 +- site/Bws.Site/Translations.cs | 28 +- site/assets/style.css | 158 +++++++++-- site/i18n/en.json | 6 - site/i18n/pl.json | 7 +- site/pages/404/pl.html | 1 + site/pages/audit-windows-services/pl.html | 1 + site/pages/cli-reference/pl.html | 13 +- site/pages/download/en.html | 1 + site/pages/download/pl.html | 3 +- site/pages/faq/en.html | 4 + site/pages/faq/pl.html | 3 +- site/pages/home/en.html | 2 +- site/pages/home/pl.html | 1 + site/pages/query-language/pl.html | 1 + site/pages/sc-exe-and-powershell/pl.html | 1 + site/pages/services-msc-alternative/pl.html | 1 + site/pages/snapshots-and-drift/pl.html | 1 + .../stop-a-service-that-will-not-stop/pl.html | 1 + site/pages/what-should-be-running/pl.html | 1 + site/site.json | 28 +- .../PublicSurfaceGuards.cs | 33 +-- tests/Bws.Site.Tests/GeneratorTests.cs | 210 ++++++++++++++- tests/mutation/entries.ps1 | 106 ++++++++ 31 files changed, 1387 insertions(+), 102 deletions(-) create mode 100644 site/Bws.Site/Freshness.cs create mode 100644 site/Bws.Site/Parity.cs diff --git a/site/Bws.Site/Build.cs b/site/Bws.Site/Build.cs index 30f16dbd..36fb804c 100644 --- a/site/Bws.Site/Build.cs +++ b/site/Bws.Site/Build.cs @@ -14,25 +14,43 @@ internal sealed class Build { private readonly string _root; private readonly string _output; + private readonly bool _requireFresh; private readonly Problems _problems = new(); + private readonly List _stale = []; - internal Build(string root, string output) + internal Build(string root, string output, bool requireFresh = false) { _root = root; _output = output; + _requireFresh = requireFresh; } internal IReadOnlyList Problems => _problems.Found; + /// + /// The translations older than the English they follow. Not problems unless the build was + /// asked to treat them as such - see for why. + /// + internal IReadOnlyList Stale => _stale; + internal int PagesWritten { get; private set; } internal void Run() { var site = SiteConfig.Load(_root); + + // Everything after this asks the registry about a language, so a registry that is wrong + // stops the build here with its own problems rather than with a missing-key exception. + site.Validate(_problems); + if (_problems.Count > 0) + { + return; + } + var facts = ProductFacts.Read(_root); var words = Translations.Load(_root, site.Languages, _problems); - var tokens = new Tokens(site, facts, words, _problems); var pages = Pages.LoadDefinitions(_root, site, _problems); + var tokens = new Tokens(site, facts, words, pages, _problems); var contents = new Dictionary<(string Id, string Language), PageContent>(); foreach (var page in pages) @@ -47,6 +65,7 @@ internal void Run() } CheckReachable(pages, site); + CheckTranslations(site, pages, contents, words); ReadmeCheck.Run(_root, facts, _problems); var renderer = new Renderer(site, words, tokens, pages, contents, _problems); @@ -72,6 +91,47 @@ internal void Run() private static UTF8Encoding Utf8 => new(false); + /// + /// What a translation owes the page it follows: its shape, its commands and its tokens + /// (), and its stamp (). The default language is + /// the original and is held to none of this. + /// + private void CheckTranslations( + SiteConfig site, + IReadOnlyList pages, + Dictionary<(string Id, string Language), PageContent> contents, + Translations words) + { + Freshness.CheckForStrays(_root, site, _problems); + + var addresses = Parity.AddressBook(pages, site); + foreach (var page in pages) + { + if (!contents.TryGetValue((page.Id, site.DefaultLanguage), out var original)) + { + continue; + } + + foreach (var language in site.Languages.Where(language => language != site.DefaultLanguage)) + { + if (contents.TryGetValue((page.Id, language), out var translation)) + { + Parity.Check(page, original, translation, addresses, _problems); + } + } + } + + Freshness.Check(_root, site, pages, contents, words, _problems, _stale); + + if (_requireFresh) + { + foreach (var line in _stale) + { + _problems.Add("freshness: " + line); + } + } + } + /// /// A page nothing links to is a page nobody will find, and on a site of fourteen it happens /// by simply forgetting a line. Reachability is counted from the chrome and from the body of @@ -98,7 +158,11 @@ private void CheckReachable(IReadOnlyList pages, SiteConfig site var text = File.ReadAllText(file); foreach (var other in pages) { - if (text.Contains("\"" + other.Url(language, site) + "\"", StringComparison.Ordinal)) + // A link written as an address, or as {{url:id}} - the second is how the + // translations write them, and a check that only knew the first would call + // every page they link to an orphan. + if (text.Contains("\"" + other.Url(language, site) + "\"", StringComparison.Ordinal) + || text.Contains("{{url:" + other.Id + "}}", StringComparison.Ordinal)) { linked.Add(other.Id); } diff --git a/site/Bws.Site/Freshness.cs b/site/Bws.Site/Freshness.cs new file mode 100644 index 00000000..e5d04153 --- /dev/null +++ b/site/Bws.Site/Freshness.cs @@ -0,0 +1,251 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace Bws.Site; + +/// +/// Which translations are older than the English they follow - and the stamp that says so. +/// +/// The mechanism. A translation carries <meta name="translated-from" content="..."> +/// (a language file carries "_translated_from"), which is a short hash of the default +/// language's file AS IT WAS when the translation was made. The build hashes the default file now +/// and compares. Different means the English moved on. +/// +/// Why a report and not a failure, by the owner's decision of 2026-10-21. The program is +/// developed daily and the English text follows it. A build that failed on every English edit +/// until twenty-one languages were redone would be switched off within a week - or worse, kept +/// green by re-stamping a page nobody retranslated, which is a lie the check cannot see. So the +/// list is printed on every build, and --require-fresh turns it into failures for the +/// moment that wants it, such as the day before a release. +/// +/// What a missing stamp is. A problem, always: without it there is nothing to compare, and +/// a translation that cannot say what it follows is a translation nobody can ever call stale. +/// +/// The hash ignores line endings and surrounding whitespace. The working tree on the +/// owner's machine is CRLF and the repository is LF; a hash that saw the difference would call +/// every translation stale on one of the two. +/// +/// What it cannot see, said here rather than found out. That a page was retranslated +/// properly. --stamp writes whatever the English hash is now, so stamping a file nobody +/// touched is possible, and nothing here can tell. The stamp is a promise by whoever stamps it. +/// Also: the language files are stamped as a whole, so one changed English sentence marks every +/// language file stale, not just the key. +/// +internal static class Freshness +{ + private static readonly Regex StampLine = new( + @"", + RegexOptions.None, + TimeSpan.FromSeconds(1)); + + private static readonly Regex JsonStampLine = new( + @"""_translated_from""\s*:\s*""[^""]*""", + RegexOptions.None, + TimeSpan.FromSeconds(1)); + + private static readonly Regex HeadLine = new( + @"^[ \t]*[ \t]*\r?$", + RegexOptions.Multiline, + TimeSpan.FromSeconds(1)); + + /// Twelve hex digits: 48 bits is not a security property, it is enough that two edits never collide by accident. + internal static string Hash(string text) + { + var normal = text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim(); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(normal)))[..12]; + } + + internal static string PagePath(string root, string pageId, string language) => + Path.Combine(root, "site", "pages", pageId, language + ".html"); + + internal static string WordsPath(string root, string language) => + Path.Combine(root, "site", "i18n", language + ".json"); + + internal static void Check( + string root, + SiteConfig site, + IReadOnlyList pages, + Dictionary<(string Id, string Language), PageContent> contents, + Translations words, + Problems problems, + List stale) + { + var source = site.DefaultLanguage; + + foreach (var page in pages) + { + if (!contents.TryGetValue((page.Id, source), out var original)) + { + continue; + } + + if (original.TranslatedFrom is not null) + { + problems.Add($"freshness: {page.Id}/{source}.html is the original and carries a translated-from stamp, which only a translation has."); + } + + var current = Hash(File.ReadAllText(PagePath(root, page.Id, source))); + foreach (var language in site.Languages.Where(language => language != source)) + { + if (!contents.TryGetValue((page.Id, language), out var translation)) + { + continue; + } + + Judge($"{page.Id}/{language}.html", translation.TranslatedFrom, current, language, problems, stale); + } + } + + var wordsNow = Hash(File.ReadAllText(WordsPath(root, source))); + foreach (var language in site.Languages.Where(language => language != source)) + { + Judge($"i18n/{language}.json", words.StampOf(language), wordsNow, language, problems, stale); + } + } + + private static void Judge(string file, string? stamp, string current, string language, Problems problems, List stale) + { + if (stamp is null) + { + problems.Add($"freshness: {file} has no translated-from stamp. Once it really follows the English, run: dotnet run --project site/Bws.Site -- --stamp {language}"); + } + else if (!string.Equals(stamp, current, StringComparison.Ordinal)) + { + stale.Add($"{file} follows English {stamp}, and the English is now {current}."); + } + } + + /// + /// Files for a language the registry has never heard of. A language that is registered and + /// not published is allowed to have files - that is how it is prepared - but a file named + /// after a code that is nowhere is a typo (pt.html for pt-br.html) and would + /// sit in the repository for ever, built by nothing. + /// + internal static void CheckForStrays(string root, SiteConfig site, Problems problems) + { + foreach (var file in Directory.EnumerateFiles(Path.Combine(root, "site", "i18n"), "*.json")) + { + var code = Path.GetFileNameWithoutExtension(file); + if (!site.LanguageRegistry.ContainsKey(code)) + { + problems.Add($"languages: site/i18n/{code}.json belongs to no language in site.json's language_registry."); + } + } + + foreach (var folder in Directory.EnumerateDirectories(Path.Combine(root, "site", "pages"))) + { + foreach (var file in Directory.EnumerateFiles(folder, "*.html")) + { + var code = Path.GetFileNameWithoutExtension(file); + if (!site.LanguageRegistry.ContainsKey(code)) + { + problems.Add($"languages: site/pages/{Path.GetFileName(folder)}/{code}.html belongs to no language in site.json's language_registry."); + } + } + } + } + + /// + /// Writes the stamp into every file of a language (or of all of them). Returns the number of + /// files written. A file is rewritten only when the stamp is not already right, so running it + /// twice changes nothing the second time. + /// + internal static int Stamp(string root, SiteConfig site, string selector, IReadOnlyList pages, out string message) + { + var languages = selector == "all" + ? site.LanguageRegistry.Keys.Where(code => code != site.DefaultLanguage).ToList() + : [selector]; + + if (selector != "all" && (selector == site.DefaultLanguage || !site.LanguageRegistry.ContainsKey(selector))) + { + message = $"'{selector}' is not a translation in site.json's language_registry (the default language is the original and is not stamped)."; + return -1; + } + + var written = 0; + foreach (var language in languages) + { + foreach (var page in pages) + { + var original = PagePath(root, page.Id, site.DefaultLanguage); + var path = PagePath(root, page.Id, language); + if (!File.Exists(original) || !File.Exists(path)) + { + continue; + } + + written += StampPage(path, Hash(File.ReadAllText(original))) ? 1 : 0; + } + + var words = WordsPath(root, language); + if (File.Exists(words)) + { + written += StampWords(words, Hash(File.ReadAllText(WordsPath(root, site.DefaultLanguage)))) ? 1 : 0; + } + } + + message = $"stamped {written} file(s) for {(selector == "all" ? "every translation" : selector)}"; + return written; + } + + private static bool StampPage(string path, string hash) + { + var text = File.ReadAllText(path); + var newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var line = $""; + + string updated; + if (StampLine.IsMatch(text)) + { + updated = StampLine.Replace(text, line, 1); + } + else + { + // After the last of the head lines, so the stamp sits with them and never in the body. + var last = HeadLine.Matches(text).LastOrDefault(); + if (last is null) + { + return false; + } + + var end = last.Index + last.Length; + var tail = text[end..]; + updated = text[..end].TrimEnd('\r') + newline + line + (text[(end - 1)..end] == "\r" ? "\r" : "") + tail; + } + + if (string.Equals(updated, text, StringComparison.Ordinal)) + { + return false; + } + + File.WriteAllText(path, updated, new UTF8Encoding(false)); + return true; + } + + private static bool StampWords(string path, string hash) + { + var text = File.ReadAllText(path); + var newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var entry = $"\"{Translations.StampKey}\": \"{hash}\""; + + string updated; + if (JsonStampLine.IsMatch(text)) + { + updated = JsonStampLine.Replace(text, entry, 1); + } + else + { + var open = text.IndexOf('{', StringComparison.Ordinal); + updated = text[..(open + 1)] + newline + " " + entry + "," + text[(open + 1)..]; + } + + if (string.Equals(updated, text, StringComparison.Ordinal)) + { + return false; + } + + File.WriteAllText(path, updated, new UTF8Encoding(false)); + return true; + } +} diff --git a/site/Bws.Site/Pages.cs b/site/Bws.Site/Pages.cs index 24078f7d..22e3ff71 100644 --- a/site/Bws.Site/Pages.cs +++ b/site/Bws.Site/Pages.cs @@ -32,12 +32,26 @@ internal sealed record PageDefinition /// Structured data to emit: software on the front page, faq on a question page, or null. public string? Schema { get; init; } - /// The slug per language. Empty for the front page. ASCII, lower case, hyphens. + /// + /// The slug per language that has slugs of its own - the registry says which. Empty for the + /// front page. ASCII, lower case, hyphens. A language the registry marks english is + /// NOT listed here, and listing it is a problem: it would be a slug nothing ever uses. + /// public required Dictionary Slug { get; init; } /// The not-found page is written to 404.html rather than to a folder, because that is where GitHub Pages looks. public bool IsNotFound => Id == "404"; + /// + /// The slug a language uses for this page. A missing own slug falls back to the default + /// language's, so the build can go on and list every other problem instead of stopping on a + /// key lookup - Pages.CheckSlugs has already said the slug is missing. + /// + internal string SlugFor(string language, SiteConfig site) => + site.Language(language).Slugs == "own" && Slug.TryGetValue(language, out var own) + ? own + : Slug[site.DefaultLanguage]; + internal string Url(string language, SiteConfig site) { var prefix = language == site.DefaultLanguage ? "/" : "/" + language + "/"; @@ -46,7 +60,7 @@ internal string Url(string language, SiteConfig site) return prefix + "404.html"; } - var slug = Slug[language]; + var slug = SlugFor(language, site); return slug.Length == 0 ? prefix : prefix + slug + "/"; } @@ -57,13 +71,17 @@ internal string OutputPath(string language, SiteConfig site) } } -/// A page's words in one language: the three head lines and the inner HTML of <main>. -internal sealed record PageContent(string Language, string Title, string Description, string NavText, string Body); +/// +/// A page's words in one language: the head lines and the inner HTML of <main>. +/// is the stamp of the default language's page this one follows, +/// which only a translation carries. +/// +internal sealed record PageContent(string Language, string Title, string Description, string NavText, string Body, string? TranslatedFrom); internal static class Pages { private static readonly Regex HeadLine = new( - @"^\s*title|description|nav)"" content=""(?[^""]*)"">\s*$", + @"^\s*title|description|nav|translated-from)"" content=""(?[^""]*)"">\s*$", RegexOptions.Multiline | RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1)); @@ -124,7 +142,7 @@ internal static List LoadDefinitions(string root, SiteConfig sit var description = head.GetValueOrDefault("description", ""); CheckLengths(page, language, title, description, problems); - return new PageContent(language, title, description, head.GetValueOrDefault("nav", title), body); + return new PageContent(language, title, description, head.GetValueOrDefault("nav", title), body, head.GetValueOrDefault("translated-from")); } /// @@ -146,13 +164,30 @@ private static void CheckLengths(PageDefinition page, string language, string ti } } + /// + /// A published language with slugs of its own needs one for every page. A language set to + /// english must have none, because the one it would have is never read. A slug for a language + /// that is registered and not yet published IS held to the same shape, so a typo is found + /// while the language is being written and not on the day it goes live. + /// private static void CheckSlugs(PageDefinition page, SiteConfig site, string path, Problems problems) { - foreach (var language in site.Languages) + foreach (var language in site.Languages.Where(language => site.Language(language).Slugs == "own" && !page.Slug.ContainsKey(language))) { - if (!page.Slug.TryGetValue(language, out var slug)) + problems.Add($"pages: {path} has no slug for '{language}'."); + } + + foreach (var (language, slug) in page.Slug) + { + if (!site.LanguageRegistry.TryGetValue(language, out var info)) + { + problems.Add($"pages: {path} has a slug for '{language}', which site.json's language_registry does not list."); + continue; + } + + if (info.Slugs != "own") { - problems.Add($"pages: {path} has no slug for '{language}'."); + problems.Add($"pages: {path} has a slug for '{language}', whose registry entry says slugs are english, so it would never be used."); continue; } @@ -162,11 +197,6 @@ private static void CheckSlugs(PageDefinition page, SiteConfig site, string path problems.Add($"pages: {path} slug '{slug}' for '{language}' is not lower case words joined by hyphens."); } } - - foreach (var language in page.Slug.Keys.Where(language => !site.Languages.Contains(language, StringComparer.Ordinal))) - { - problems.Add($"pages: {path} has a slug for '{language}', which site.json does not list."); - } } private static void CheckSlugsAreDistinct(List pages, SiteConfig site, Problems problems) @@ -174,7 +204,6 @@ private static void CheckSlugsAreDistinct(List pages, SiteConfig foreach (var language in site.Languages) { var clashes = pages - .Where(page => page.Slug.ContainsKey(language)) .GroupBy(page => page.Url(language, site), StringComparer.Ordinal) .Where(group => group.Count() > 1); diff --git a/site/Bws.Site/Parity.cs b/site/Bws.Site/Parity.cs new file mode 100644 index 00000000..181a0b7e --- /dev/null +++ b/site/Bws.Site/Parity.cs @@ -0,0 +1,248 @@ +using System.Text.RegularExpressions; + +namespace Bws.Site; + +/// +/// A translation held against the page in the default language it follows - not for the quality +/// of its sentences, which nothing here can judge, but for the three ways a translation goes +/// wrong that a build CAN see. +/// +/// The shape. The block structure has to be the English page's: the same sections, the +/// same headings, paragraphs, lists and table cells, in the same order, with the same classes and +/// ids, linking to the same pages and the same outside addresses. A translator who drops a +/// paragraph, merges two list items or leaves a closing tag off produces a page that looks +/// finished and is not the page it claims to be. Inline formatting (em, strong, +/// code, span, wbr) is NOT structure: a language may emphasise a different +/// word, so it is left out of the comparison. +/// +/// The commands. Every <code> has to hold what the English one holds, minus the +/// comment spans of a code block, which are prose and are translated, and except for the word +/// inside an angle-bracket placeholder, which is the reader's to fill in. A command the visitor will +/// paste into a shell is the one thing on the page that must not be a translation. +/// +/// The tokens. The same {{...}} placeholders, the same number of times. A page that +/// loses {{releases}} loses the link to the download, and one that gains a token the +/// English page does not use is a sentence about something the English page does not say. +/// +/// Written 2026-10-21, measured first. English and Polish agree on all of this on ten of +/// twelve pages; the two that did not (the question about the program's own language) differed +/// in CONTENT, not by a slip, and were reconciled in the English source before this check went in. +/// +internal static class Parity +{ + private static readonly HashSet Inline = new(StringComparer.Ordinal) + { + "em", "strong", "code", "span", "wbr", "br", "b", "i", "sub", "sup", "kbd", "abbr", "mark", "small", + }; + + private static readonly Regex Tag = new( + @"<(?/)?(?[a-zA-Z][a-zA-Z0-9-]*)(?(?:[^>""']|""[^""]*""|'[^']*')*)>", + RegexOptions.ExplicitCapture, + TimeSpan.FromSeconds(5)); + + private static readonly Regex Attribute = new( + @"(?<=\s)(?class|id|href|src)\s*=\s*(?:""(?[^""]*)""|'(?[^']*)')", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(2)); + + private static readonly Regex CodeElement = new( + @"]*>(?.*?)", + RegexOptions.ExplicitCapture | RegexOptions.Singleline, + TimeSpan.FromSeconds(5)); + + private static readonly Regex CommentSpan = new( + @".*?", + RegexOptions.Singleline, + TimeSpan.FromSeconds(5)); + + private static readonly Regex AnyTag = new("<[^>]*>", RegexOptions.None, TimeSpan.FromSeconds(2)); + + private static readonly Regex Token = new( + @"\{\{\s*(?[a-z0-9:_-]+)\s*\}\}", + RegexOptions.ExplicitCapture, + TimeSpan.FromSeconds(2)); + + private static readonly Regex Whitespace = new(@"\s+", RegexOptions.None, TimeSpan.FromSeconds(1)); + + // A word in angle brackets is the part of a command the reader replaces - in a file + // name, in a pattern - and the one part of a command that a translation SHOULD + // translate, because the reader has to understand what to put there. The brackets stay, the + // word between them is not compared. + private static readonly Regex Placeholder = new(@"<[^&]*?>", RegexOptions.None, TimeSpan.FromSeconds(1)); + + /// The address of every page in every published language, mapped to the id of the page, so that a link means the same page whatever its spelling. + internal static Dictionary AddressBook(IReadOnlyList pages, SiteConfig site) + { + var book = new Dictionary(StringComparer.Ordinal); + foreach (var page in pages) + { + foreach (var language in site.Languages) + { + book[page.Url(language, site)] = page.Id; + } + } + + return book; + } + + internal static void Check( + PageDefinition page, + PageContent original, + PageContent translation, + Dictionary addresses, + Problems problems) + { + var where = $"{page.Id}/{translation.Language}.html"; + + var wanted = Skeleton(original.Body, addresses); + var found = Skeleton(translation.Body, addresses); + var firstDifference = FirstDifference(wanted, found); + if (firstDifference >= 0) + { + problems.Add( + $"parity: {where} does not have the shape of the {original.Language} page. " + + $"Element {firstDifference + 1} of {wanted.Count} should be '{At(wanted, firstDifference)}' and is '{At(found, firstDifference)}'" + + (wanted.Count == found.Count ? "." : $" - the English page has {wanted.Count} and this one has {found.Count}.")); + } + + var wantedCode = Commands(original.Body); + var foundCode = Commands(translation.Body); + var missing = Without(wantedCode, foundCode); + var extra = Without(foundCode, wantedCode); + if (missing.Count > 0 || extra.Count > 0) + { + problems.Add( + $"parity: {where} has different code from the {original.Language} page" + + (missing.Count > 0 ? $" - missing: {Quote(missing)}" : "") + + (extra.Count > 0 ? $" - not in the original: {Quote(extra)}" : "") + + "."); + } + + var wantedTokens = TokenNames(original.Body); + var foundTokens = TokenNames(translation.Body); + var tokenMissing = Without(wantedTokens, foundTokens); + var tokenExtra = Without(foundTokens, wantedTokens); + if (tokenMissing.Count > 0 || tokenExtra.Count > 0) + { + problems.Add( + $"parity: {where} uses different tokens from the {original.Language} page" + + (tokenMissing.Count > 0 ? $" - missing: {string.Join(", ", tokenMissing)}" : "") + + (tokenExtra.Count > 0 ? $" - not in the original: {string.Join(", ", tokenExtra)}" : "") + + "."); + } + } + + /// The block structure of a fragment as one line per element, with the attributes that make it the same element. + internal static List Skeleton(string body, Dictionary addresses) + { + var items = new List(); + foreach (var match in Tag.Matches(body).Cast()) + { + var name = match.Groups["name"].Value.ToLowerInvariant(); + if (Inline.Contains(name)) + { + continue; + } + + if (match.Groups["close"].Success) + { + items.Add("/" + name); + continue; + } + + var line = name; + foreach (var attribute in Attribute.Matches(match.Groups["attributes"].Value).Cast()) + { + var key = attribute.Groups["key"].Value.ToLowerInvariant(); + var value = attribute.Groups["value"].Value; + line += key switch + { + "class" => "." + string.Join(".", value.Split(' ', StringSplitOptions.RemoveEmptyEntries)), + "id" => "#" + value, + "href" => " ->" + Destination(value, addresses), + _ => " " + key + "=" + value, + }; + } + + items.Add(line); + } + + return items; + } + + /// + /// A link's destination as the page it means, so /faq/, /de/haeufige-fragen/ and + /// {{url:faq}} are all 'the FAQ'. An outside address and an in-page anchor stay as written. + /// + private static string Destination(string href, Dictionary addresses) + { + var hash = href.IndexOf('#', StringComparison.Ordinal); + var path = hash < 0 ? href : href[..hash]; + var fragment = hash < 0 ? "" : href[hash..]; + + var token = Token.Match(path); + if (token.Success && token.Value.Length == path.Length && token.Groups["name"].Value.StartsWith("url:", StringComparison.Ordinal)) + { + return "page:" + token.Groups["name"].Value["url:".Length..] + fragment; + } + + return addresses.TryGetValue(path, out var id) ? "page:" + id + fragment : href; + } + + private static List Commands(string body) => + CodeElement.Matches(body).Cast() + .Select(match => CommentSpan.Replace(match.Groups["text"].Value, "")) + .Select(text => Placeholder.Replace(Whitespace.Replace(AnyTag.Replace(text, ""), " ").Trim(), "<>")) + .Where(text => text.Length > 0) + .Order(StringComparer.Ordinal) + .ToList(); + + private static List TokenNames(string body) => + Token.Matches(body).Cast() + .Select(match => match.Groups["name"].Value) + .Where(name => !name.StartsWith("url:", StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + .ToList(); + + private static int FirstDifference(List wanted, List found) + { + var shared = Math.Min(wanted.Count, found.Count); + for (var index = 0; index < shared; index++) + { + if (!string.Equals(wanted[index], found[index], StringComparison.Ordinal)) + { + return index; + } + } + + return wanted.Count == found.Count ? -1 : shared; + } + + private static string At(List items, int index) => index < items.Count ? items[index] : "(nothing)"; + + /// The elements of that has fewer of, counting repeats. + private static List Without(List from, List other) + { + var remaining = other.GroupBy(item => item, StringComparer.Ordinal).ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + var result = new List(); + foreach (var item in from) + { + if (remaining.TryGetValue(item, out var count) && count > 0) + { + remaining[item] = count - 1; + continue; + } + + result.Add(item); + } + + return result; + } + + private static string Quote(List items) + { + const int Shown = 3; + var shown = items.Take(Shown).Select(item => "'" + (item.Length > 60 ? item[..60] + "..." : item) + "'"); + return string.Join(", ", shown) + (items.Count > Shown ? $" and {items.Count - Shown} more" : ""); + } +} diff --git a/site/Bws.Site/Program.cs b/site/Bws.Site/Program.cs index 9f0a6312..cf23d6b3 100644 --- a/site/Bws.Site/Program.cs +++ b/site/Bws.Site/Program.cs @@ -4,12 +4,22 @@ // // dotnet run --project site/Bws.Site -- --out artifacts/site // dotnet run --project site/Bws.Site -- --out artifacts/site --strict +// dotnet run --project site/Bws.Site -- --out artifacts/site --strict --require-fresh +// dotnet run --project site/Bws.Site -- --stamp de // // --strict turns every problem into exit code 1 and is what the workflow uses. Without it the // site is written anyway and the problems are printed, so a page can be looked at while its // second language is half done - and the list says exactly how half. +// +// --require-fresh makes a translation that is older than the English it follows a problem as +// well. By default it is only listed - see Freshness for why that is the default. +// +// --stamp CODE (or --stamp all) records that a language's files follow the English as it is NOW. +// Run it once the translation really does, never to make a list go away. var strict = false; +var requireFresh = false; +string? stamp = null; string? root = null; var output = "artifacts/site"; @@ -21,6 +31,14 @@ strict = true; break; + case "--require-fresh": + requireFresh = true; + break; + + case "--stamp" when index + 1 < args.Length: + stamp = args[++index]; + break; + case "--out" when index + 1 < args.Length: output = args[++index]; break; @@ -30,7 +48,7 @@ break; default: - Console.Error.WriteLine($"bws-site: unknown argument '{args[index]}'. Takes --out DIR, --root DIR, --strict."); + Console.Error.WriteLine($"bws-site: unknown argument '{args[index]}'. Takes --out DIR, --root DIR, --strict, --require-fresh, --stamp CODE."); return 2; } } @@ -38,9 +56,31 @@ try { var repository = SourceTree.Root(root); + + if (stamp is not null) + { + var setup = new Problems(); + var site = SiteConfig.Load(repository); + site.Validate(setup); + var definitions = Pages.LoadDefinitions(repository, site, setup); + if (setup.Count > 0) + { + foreach (var problem in setup.Found) + { + Console.Error.WriteLine(" " + problem); + } + + return 1; + } + + var written = Freshness.Stamp(repository, site, stamp, definitions, out var message); + Console.WriteLine("bws-site: " + message); + return written < 0 ? 2 : 0; + } + var target = Path.IsPathRooted(output) ? output : Path.Combine(repository, output); - var build = new Build(repository, target); + var build = new Build(repository, target, requireFresh); build.Run(); foreach (var problem in build.Problems) @@ -48,6 +88,15 @@ Console.Error.WriteLine(" " + problem); } + if (build.Stale.Count > 0) + { + Console.WriteLine($"bws-site: {build.Stale.Count} translation file(s) are older than the English they follow:"); + foreach (var line in build.Stale) + { + Console.WriteLine(" stale: " + line); + } + } + Console.WriteLine($"bws-site: {build.PagesWritten} pages into {target}"); if (build.Problems.Count == 0) diff --git a/site/Bws.Site/Renderer.cs b/site/Bws.Site/Renderer.cs index a6122ee7..9f181b08 100644 --- a/site/Bws.Site/Renderer.cs +++ b/site/Bws.Site/Renderer.cs @@ -40,6 +40,7 @@ internal Renderer( internal string Render(PageDefinition page, PageContent content) { var language = content.Language; + var info = _site.Language(language); var url = _site.Origin + page.Url(language, _site); var title = _tokens.Resolve(content.Title, language, $"{page.Id}/{language} title"); var description = _tokens.Resolve(content.Description, language, $"{page.Id}/{language} description"); @@ -47,7 +48,11 @@ internal string Render(PageDefinition page, PageContent content) var html = new StringBuilder(); html.AppendLine(""); - html.AppendLine(CultureInfo.InvariantCulture, $""); + + // dir is written only when it is not the default. A browser assumes ltr, and an attribute + // saying so on twenty-one pages would be twenty-one places to be wrong about it. + var direction = info.Dir == "ltr" ? "" : $" dir=\"{info.Dir}\""; + html.AppendLine(CultureInfo.InvariantCulture, $""); html.AppendLine(""); html.AppendLine(""); html.AppendLine(""); @@ -105,7 +110,7 @@ private void AppendAlternates(StringBuilder html, PageDefinition page) foreach (var language in _site.Languages) { - html.AppendLine(CultureInfo.InvariantCulture, $""); + html.AppendLine(CultureInfo.InvariantCulture, $""); } html.AppendLine(CultureInfo.InvariantCulture, $""); @@ -115,10 +120,10 @@ private void AppendOpenGraph(StringBuilder html, PageDefinition page, string lan { html.AppendLine(""); html.AppendLine(CultureInfo.InvariantCulture, $""); - html.AppendLine(CultureInfo.InvariantCulture, $""); + html.AppendLine(CultureInfo.InvariantCulture, $""); foreach (var other in _site.Languages.Where(other => other != language)) { - html.AppendLine(CultureInfo.InvariantCulture, $""); + html.AppendLine(CultureInfo.InvariantCulture, $""); } html.AppendLine(CultureInfo.InvariantCulture, $""); @@ -136,13 +141,6 @@ private void AppendOpenGraph(StringBuilder html, PageDefinition page, string lan _ = page; } - private static string Locale(string language) => language switch - { - "en" => "en_US", - "pl" => "pl_PL", - _ => language, - }; - /// /// Structured data, on the two pages where there is something true to say: the front page /// describes the program, and the questions page marks its questions up as questions. @@ -197,6 +195,20 @@ private void AppendHeader(StringBuilder html, PageDefinition current, string lan } /// + /// Rewritten 2026-10-21 for twenty-two languages. A row of two-letter links was the + /// right size for one other language and cannot be for twenty-one: it would be a second + /// navigation bar. So it is a disclosure - a <details> whose summary shows the + /// language you are in and whose list holds all of them under their own names. No script, + /// because the site promises to run none, and <details> is the one control that + /// opens by keyboard and by touch in every browser without one. + /// + /// The price, said out loud: it does not close when you click elsewhere or press + /// Escape, because that needs script or the popover attribute, which a browser from before + /// 2024 does not understand and then prints the list in the page. A disclosure that stays + /// open until you press it again was judged the lesser fault. + /// + /// The original reasoning about where the switch sits follows, and still holds. + /// /// The language switch, which is OUTSIDE the navigation and that is the whole point of it /// being its own method. /// @@ -217,24 +229,35 @@ private void AppendHeader(StringBuilder html, PageDefinition current, string lan /// private void AppendLanguageSwitch(StringBuilder html, PageDefinition current, string language) { - var others = _site.Languages.Where(other => other != language).ToList(); - if (others.Count == 0) + if (_site.Languages.Length < 2) { return; } - html.AppendLine(CultureInfo.InvariantCulture, $"
"); + var label = _words.Get(language, "chrome.language", _problems); + var here = _site.Language(language); - foreach (var other in others) + // The accessible name carries the language you are in as well as the word for "language": + // a screen reader that said only "Language" would leave you to find out which by opening it. + html.AppendLine("
"); + html.AppendLine(CultureInfo.InvariantCulture, $"{Html.Escape(here.Short)}"); + html.AppendLine(CultureInfo.InvariantCulture, $"
    "); + + foreach (var code in _site.Languages) { - var target = current.IsNotFound ? Home(other) : current.Url(other, _site); - var full = _words.Get(language, "chrome.language." + other, _problems); - var code = _words.Get(language, "chrome.language.short." + other, _problems); + var info = _site.Language(code); + if (code == language) + { + html.AppendLine(CultureInfo.InvariantCulture, $"
  • {Html.Escape(info.Name)}
  • "); + continue; + } - html.AppendLine(CultureInfo.InvariantCulture, $"{Html.Escape(code)}"); + var target = current.IsNotFound ? Home(code) : current.Url(code, _site); + html.AppendLine(CultureInfo.InvariantCulture, $"
  • {Html.Escape(info.Name)}
  • "); } - html.AppendLine("
"); + html.AppendLine(""); + html.AppendLine(""); } private void AppendFooter(StringBuilder html, string language) diff --git a/site/Bws.Site/SiteConfig.cs b/site/Bws.Site/SiteConfig.cs index 085f6fb8..0c6e795a 100644 --- a/site/Bws.Site/SiteConfig.cs +++ b/site/Bws.Site/SiteConfig.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace Bws.Site; /// @@ -16,9 +18,19 @@ internal sealed record SiteConfig public required string Support { get; init; } - /// The first language is the default and lives at the root, the others under /{code}/. + /// + /// The languages that are PUBLISHED, in the order the switch lists them. The first is the + /// default and lives at the root, the others under /{code}/. + /// public required string[] Languages { get; init; } + /// + /// Every language the site is prepared for, published or not. A language may be in the + /// registry before it is in : its files can be written, built and + /// checked while nobody can reach them, and it goes live by one line in the other list. + /// + public required Dictionary LanguageRegistry { get; init; } + public required string ThemeColor { get; init; } public required SocialImage SocialImage { get; init; } @@ -43,6 +55,11 @@ internal sealed record SiteConfig public string DefaultLanguage => Languages[0]; + private static readonly Regex CodeShape = new("^[a-z]{2,3}(-[a-z]{2,4})?$", RegexOptions.None, TimeSpan.FromSeconds(1)); + + /// What the registry says about a language, which has already held to be present. + internal LanguageInfo Language(string code) => LanguageRegistry[code]; + internal static SiteConfig Load(string root) { var path = Path.Combine(root, "site", "site.json"); @@ -55,6 +72,94 @@ internal static SiteConfig Load(string root) return config; } + + /// + /// The registry held against itself and against the list of published languages. + /// + /// Why this is a method and not part of . Everything else in this + /// build reports what it finds and goes on, so a half-finished language can be looked at. A + /// registry that is wrong cannot be gone on from - every page asks it for a tag - so the + /// build stops reading it as soon as it has said what is wrong. + /// + internal void Validate(Problems problems) + { + foreach (var (code, info) in LanguageRegistry) + { + if (!CodeShape.IsMatch(code)) + { + problems.Add($"site.json: language code '{code}' is not lower case letters, optionally joined to a script or region by a hyphen."); + } + + if (info.Dir is not ("ltr" or "rtl")) + { + problems.Add($"site.json: language '{code}' says dir '{info.Dir}', which is neither ltr nor rtl."); + } + + if (info.Slugs is not ("own" or "english")) + { + problems.Add($"site.json: language '{code}' says slugs '{info.Slugs}', which is neither own nor english."); + } + + if (info.Tag.Length == 0 || info.Locale.Length == 0 || info.Name.Length == 0 || info.Short.Length == 0) + { + problems.Add($"site.json: language '{code}' leaves its tag, locale, name or short empty."); + } + + if (!string.Equals(info.Tag, code, StringComparison.OrdinalIgnoreCase)) + { + problems.Add($"site.json: language '{code}' has the tag '{info.Tag}', which is the same code written differently only by case."); + } + } + + foreach (var clash in LanguageRegistry.GroupBy(pair => pair.Value.Locale, StringComparer.Ordinal).Where(group => group.Count() > 1)) + { + problems.Add($"site.json: languages {string.Join(", ", clash.Select(pair => pair.Key))} share the locale {clash.Key}."); + } + + foreach (var code in Languages.Where(code => !LanguageRegistry.ContainsKey(code))) + { + problems.Add($"site.json: '{code}' is published under languages and has no entry in language_registry."); + } + + if (Languages.Distinct(StringComparer.Ordinal).Count() != Languages.Length) + { + problems.Add("site.json: languages lists a language twice."); + } + + if (LanguageRegistry.TryGetValue(DefaultLanguage, out var first) && first.Slugs != "own") + { + problems.Add($"site.json: the default language '{DefaultLanguage}' has to keep its own slugs - the others fall back to them."); + } + } +} + +/// +/// One language as the site knows it. The registry is the ONE place that says how a language is +/// written, so a page, the switch and the head of every other page agree by construction. +/// +internal sealed record LanguageInfo +{ + /// The BCP 47 tag for lang and hreflang: pt-BR, zh-Hans. The URL code is the same in lower case. + public required string Tag { get; init; } + + /// The og:locale value. It is not derivable from the tag - zh-Hans is zh_CN to the people who read it. + public required string Locale { get; init; } + + /// The language in its OWN alphabet, never translated: somebody looking for German looks for 'Deutsch'. + public required string Name { get; init; } + + /// What the switch shows for the current language. Short, because the header has no room for a name. + public required string Short { get; init; } + + /// ltr or rtl. The attribute is written only for rtl, which is what a browser assumes otherwise. + public string Dir { get; init; } = "ltr"; + + /// + /// own - the language has a slug of its own for every page - or english - its + /// pages live at the default language's slugs. The second is for alphabets that cannot be an + /// ASCII address without being transliterated, which nobody would then read. + /// + public string Slugs { get; init; } = "own"; } internal sealed record SocialImage diff --git a/site/Bws.Site/Tokens.cs b/site/Bws.Site/Tokens.cs index fe9a5724..4e42e199 100644 --- a/site/Bws.Site/Tokens.cs +++ b/site/Bws.Site/Tokens.cs @@ -15,11 +15,17 @@ internal sealed class Tokens { private static readonly Regex Placeholder = new(@"\{\{\s*(?[a-z0-9:_-]+)\s*\}\}", RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(2)); + private const string UrlPrefix = "url:"; + private readonly Dictionary> _byName; + private readonly SiteConfig _site; + private readonly IReadOnlyList _pages; private readonly Problems _problems; - internal Tokens(SiteConfig site, ProductFacts facts, Translations words, Problems problems) + internal Tokens(SiteConfig site, ProductFacts facts, Translations words, IReadOnlyList pages, Problems problems) { + _site = site; + _pages = pages; _problems = problems; _byName = new Dictionary>(StringComparer.Ordinal) { @@ -45,10 +51,29 @@ internal Tokens(SiteConfig site, ProductFacts facts, Translations words, Problem }; } + /// + /// {{url:ID}} is the address of another page in the language of the page that + /// writes it. Written 2026-10-21 for the translations: a language with slugs of its own + /// has a different address for every page, and a link typed by hand there is a link that is + /// right until the slug is edited. A token cannot be mistyped into a different valid + /// address, and the link check still holds what it resolves to against the built site. + /// internal string Resolve(string text, string language, string where) => Placeholder.Replace(text, match => { var name = match.Groups["name"].Value; + if (name.StartsWith(UrlPrefix, StringComparison.Ordinal)) + { + var id = name[UrlPrefix.Length..]; + if (_pages.FirstOrDefault(page => page.Id == id) is { } target) + { + return target.Url(language, _site); + } + + _problems.Add($"tokens: {where} uses {{{{{name}}}}}, and no page has the id '{id}'."); + return match.Value; + } + if (_byName.TryGetValue(name, out var value)) { return value(language); diff --git a/site/Bws.Site/Translations.cs b/site/Bws.Site/Translations.cs index 09d638eb..492310d2 100644 --- a/site/Bws.Site/Translations.cs +++ b/site/Bws.Site/Translations.cs @@ -15,18 +15,27 @@ namespace Bws.Site; /// internal sealed class Translations { + /// The key that records which English file a language file was translated from. Underscore, so it is never a word. + internal const string StampKey = "_translated_from"; + private readonly Dictionary> _byLanguage; + private readonly Dictionary _stamps; - private Translations(Dictionary> byLanguage) + private Translations(Dictionary> byLanguage, Dictionary stamps) { _byLanguage = byLanguage; + _stamps = stamps; } internal IEnumerable Keys => _byLanguage.Values.First().Keys; + /// The stamp a language file carries, or null when it has none. + internal string? StampOf(string language) => _stamps.GetValueOrDefault(language); + internal static Translations Load(string root, IEnumerable languages, Problems problems) { var byLanguage = new Dictionary>(StringComparer.Ordinal); + var stamps = new Dictionary(StringComparer.Ordinal); foreach (var language in languages) { var path = Path.Combine(root, "site", "i18n", language + ".json"); @@ -37,11 +46,15 @@ internal static Translations Load(string root, IEnumerable languages, Pr continue; } - byLanguage[language] = Read(path, problems); + byLanguage[language] = Read(path, problems, out var stamp); + if (stamp is not null) + { + stamps[language] = stamp; + } } CheckParity(byLanguage, problems); - return new Translations(byLanguage); + return new Translations(byLanguage, stamps); } /// The text for a key, or the key itself in brackets - and a problem - when there is none. @@ -59,12 +72,19 @@ internal string Get(string language, string key, Problems problems) internal bool Has(string language, string key) => _byLanguage.TryGetValue(language, out var words) && words.ContainsKey(key); - private static Dictionary Read(string path, Problems problems) + private static Dictionary Read(string path, Problems problems, out string? stamp) { + stamp = null; var words = new Dictionary(StringComparer.Ordinal); using var document = JsonDocument.Parse(File.ReadAllText(path), new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip }); foreach (var property in document.RootElement.EnumerateObject()) { + if (property.Name == StampKey && property.Value.ValueKind == JsonValueKind.String) + { + stamp = property.Value.GetString(); + continue; + } + if (property.Name.StartsWith('_')) { continue; diff --git a/site/assets/style.css b/site/assets/style.css index 8707815c..9218c23c 100644 --- a/site/assets/style.css +++ b/site/assets/style.css @@ -253,7 +253,7 @@ img, svg { display: block; max-width: 100%; height: auto; } .skip { position: absolute; - left: var(--s-4); + inset-inline-start: var(--s-4); top: -100%; z-index: 100; background: var(--action); @@ -349,21 +349,28 @@ img, svg { display: block; max-width: 100%; height: auto; } } /* - The language switch, pinned to the right of the first row. `margin-left: auto` on a flex item - pushes it away from the nav when the nav is narrower than the space, and does nothing when it - is not - so one rule covers both the wide header and the wrapped one. + The language switch, pinned to the end of the first row. `margin-inline-start: auto` on a flex + item pushes it away from the nav when the nav is narrower than the space, and does nothing when + it is not - so one rule covers both the wide header and the wrapped one, and in a right-to-left + language "the end" is the left without a second rule. + + IT IS A DISCLOSURE (details), NOT A ROW OF LINKS, because there are twenty-two languages. The + summary is the button - the code of the language you are in - and the list under it holds every + language under its own name. Nothing here runs a script. */ .lang-switch { - display: flex; - align-items: center; - gap: var(--s-1); - margin-left: auto; + position: relative; + margin-inline-start: auto; flex: 0 0 auto; } -.lang-switch .lang { +.lang-switch summary { + display: inline-flex; + align-items: center; + gap: var(--s-2); + list-style: none; + cursor: pointer; color: var(--text-2); - text-decoration: none; padding: var(--s-2) var(--s-3); border: 1px solid var(--edge); border-radius: var(--radius); @@ -371,13 +378,72 @@ img, svg { display: block; max-width: 100%; height: auto; } font-size: var(--fs-0); font-weight: 600; letter-spacing: 0.04em; + user-select: none; +} + +.lang-switch summary::-webkit-details-marker { display: none; } + +/* The chevron is a rotated corner of a box, so it takes the text colour and needs no image. */ +.lang-switch summary::after { + content: ""; + width: 0.45em; + height: 0.45em; + border-inline-end: 2px solid currentColor; + border-block-end: 2px solid currentColor; + transform: translateY(-0.12em) rotate(45deg); +} + +.lang-switch[open] summary::after { transform: translateY(0.1em) rotate(-135deg); } + +.lang-switch summary:hover, .lang-switch[open] summary { + color: var(--text); + background: var(--bg-hover); +} + +.lang-list { + position: absolute; + inset-block-start: calc(100% + var(--s-2)); + inset-inline-end: 0; + z-index: 50; + margin: 0; + padding: var(--s-2); + list-style: none; + display: grid; + grid-template-columns: repeat(4, minmax(10rem, 1fr)); + gap: var(--s-1); + width: max-content; + max-width: calc(100vw - 2 * var(--gutter)); + max-height: calc(100vh - var(--header-height) - var(--s-6)); + overflow-y: auto; + background: var(--bg-card); + border: 1px solid var(--edge); + border-radius: var(--radius-lg); + box-shadow: 0 var(--s-5) var(--s-7) rgba(0, 0, 0, 0.45); +} + +.lang-list .lang { + display: flex; + align-items: center; + min-height: var(--control-height); + padding: var(--s-2) var(--s-3); + border-radius: var(--radius); + color: var(--text-2); + text-decoration: none; + font-size: var(--fs-1); + overflow-wrap: anywhere; } -.lang-switch .lang:hover { +.lang-list a.lang:hover { color: var(--text); background: var(--bg-hover); } +.lang-list [aria-current="true"] { + color: var(--text); + background: var(--bg-hover); + font-weight: 600; +} + /* ---- Buttons ---------------------------------------------------------------------- */ .btn { @@ -540,7 +606,7 @@ main { flex: 1; } } .points li { - padding-left: var(--s-5); + padding-inline-start: var(--s-5); position: relative; max-width: none; } @@ -548,7 +614,7 @@ main { flex: 1; } .points li::before { content: ""; position: absolute; - left: 0; + inset-inline-start: 0; top: 0.55em; width: var(--dot); height: var(--dot); @@ -572,7 +638,7 @@ table { } th, td { - text-align: left; + text-align: start; vertical-align: top; padding: var(--s-3) var(--s-4); border-bottom: 1px solid var(--rule); @@ -636,7 +702,7 @@ figcaption { .prose { max-width: var(--measure); } .prose h2 { margin-top: var(--s-7); margin-bottom: var(--s-4); } .prose h3 { margin-top: var(--s-6); margin-bottom: var(--s-3); } -.prose ul, .prose ol { padding-left: var(--s-5); } +.prose ul, .prose ol { padding-inline-start: var(--s-5); } .prose li + li { margin-top: var(--s-2); } .prose pre, .prose .table-wrap, .prose table { max-width: none; } @@ -653,9 +719,12 @@ figcaption { /* ---- Notes ------------------------------------------------------------------------ */ .note { - border-left: 3px solid var(--warning); + border-inline-start: 3px solid var(--warning); background: var(--bg-card); - border-radius: 0 var(--radius) var(--radius) 0; + border-start-start-radius: 0; + border-end-start-radius: 0; + border-start-end-radius: var(--radius); + border-end-end-radius: var(--radius); padding: var(--s-3) var(--s-4); color: var(--text-2); max-width: var(--measure); @@ -724,6 +793,7 @@ figcaption { */ .site-nav { width: 100%; flex-basis: 100%; order: 1; } .site-header { position: static; } + .lang-list { grid-template-columns: repeat(3, minmax(0, 1fr)); width: min(34rem, calc(100vw - 2 * var(--gutter))); } } @media (max-width: 36rem) { @@ -737,8 +807,62 @@ figcaption { .examples thead { display: none; } .examples td { display: block; width: auto; } .examples td:first-child { width: auto; border-bottom: 0; padding-bottom: 0; } + + .lang-list { grid-template-columns: repeat(2, minmax(0, 1fr)); width: calc(100vw - 2 * var(--gutter)); } } +/* ---- Languages -------------------------------------------------------------------- */ + +/* + WHAT THE LANGUAGES NEED THAT ENGLISH AND POLISH DID NOT. Written 2026-10-21 for twenty more. + + Right to left. The layout uses logical properties above (inline-start, inline-end), so the + mirror is the browser's. What it cannot mirror is code: a command, a path and a query are read + left to right in every language, so they are isolated from the direction of the page around + them. Without that, `bws list --query "start:auto"` in an Arabic sentence has its pieces + reordered and still looks like a command. +*/ +code, pre, kbd, samp { + direction: ltr; + unicode-bidi: isolate; +} + +pre { text-align: left; } + +/* An arrow that points forward points the other way when forward is left. */ +[dir="rtl"] .more::after { content: " \2190"; } + +/* + Scripts that join or stack. Letter spacing separates the letters of a cursive script, which + breaks the joins that make the word readable - Arabic written with spaced letters is not Arabic + with a wider look, it is wrong. The headings also carry a negative spacing for Latin that does + nothing good for any of these. +*/ +html:lang(ar) *, html:lang(ar) *::before, html:lang(ar) *::after, +html:lang(ja) *, html:lang(ko) *, html:lang(zh-Hans) *, html:lang(zh-Hant) *, +html:lang(th) *, html:lang(hi) * { + letter-spacing: 0; +} + +/* + Type. The page uses the system face and downloads nothing, so each script names the faces that + carry it on Windows, macOS and Linux, after the Latin face so that Latin letters and numerals + inside the text keep the site's look. Without a name the browser falls back to whatever it + finds first, which for Han characters can be the wrong regional form - `lang` helps, an + explicit face is what makes it certain. + + Line height. Thai, Devanagari and Arabic carry marks above and below the line that a 1.6 line + clips into the next one, and a heading at 1.15 collides outright. +*/ +html:lang(ja) { --font: "Segoe UI Variable Text", "Segoe UI", "Yu Gothic UI", "Meiryo UI", "Hiragino Sans", "Noto Sans JP", "Noto Sans CJK JP", system-ui, sans-serif; --lh-body: 1.75; --lh-tight: 1.35; } +html:lang(ko) { --font: "Segoe UI Variable Text", "Segoe UI", "Malgun Gothic", "Apple SD Gothic Neo", "Noto Sans KR", "Noto Sans CJK KR", system-ui, sans-serif; --lh-body: 1.75; --lh-tight: 1.35; } +html:lang(zh-Hans) { --font: "Segoe UI Variable Text", "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", "Noto Sans SC", "Noto Sans CJK SC", system-ui, sans-serif; --lh-body: 1.75; --lh-tight: 1.35; } +html:lang(zh-Hant) { --font: "Segoe UI Variable Text", "Segoe UI", "Microsoft JhengHei UI", "Microsoft JhengHei", "PingFang TC", "Noto Sans TC", "Noto Sans CJK TC", system-ui, sans-serif; --lh-body: 1.75; --lh-tight: 1.35; } +html:lang(th) { --font: "Segoe UI Variable Text", "Segoe UI", "Leelawadee UI", "Tahoma", "Noto Sans Thai", system-ui, sans-serif; --lh-body: 1.8; --lh-tight: 1.4; } +html:lang(hi) { --font: "Segoe UI Variable Text", "Segoe UI", "Nirmala UI", "Kohinoor Devanagari", "Noto Sans Devanagari", system-ui, sans-serif; --lh-body: 1.8; --lh-tight: 1.4; } +html:lang(ar) { --font: "Segoe UI Variable Text", "Segoe UI", "Tahoma", "Geeza Pro", "Noto Sans Arabic", system-ui, sans-serif; --lh-body: 1.8; --lh-tight: 1.4; } +html:lang(vi) { --lh-body: 1.7; --lh-tight: 1.3; } + @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } diff --git a/site/i18n/en.json b/site/i18n/en.json index ee9a50f3..7f876ebe 100644 --- a/site/i18n/en.json +++ b/site/i18n/en.json @@ -4,12 +4,6 @@ "chrome.skip": "Skip to content", "chrome.nav": "Site", "chrome.language": "Language", - "chrome.language.en": "English", - "chrome.language.pl": "Polski", - - "_language_short": "The two letters on the switch itself. The full name above is what a screen reader says and what the tooltip shows - the code is what fits. A language's own name and its own code, never translated: a Polish reader looking for Polish looks for 'Polski', not for whatever English calls it.", - "chrome.language.short.en": "EN", - "chrome.language.short.pl": "PL", "chrome.footer.product": "Product", "chrome.footer.guides": "Guides", diff --git a/site/i18n/pl.json b/site/i18n/pl.json index 59878b00..68c7a664 100644 --- a/site/i18n/pl.json +++ b/site/i18n/pl.json @@ -1,15 +1,10 @@ { + "_translated_from": "1867e30d6665", "_note": "Polska wersja chromu strony i zdan przy tabelach, ktore generator buduje z programu. Klucze musza byc dokladnie te same co w en.json - brakujacy klucz wywala budowanie, zamiast po cichu spasc do angielskiego.", "chrome.skip": "Przejdź do treści", "chrome.nav": "Strona", "chrome.language": "Język", - "chrome.language.en": "English", - "chrome.language.pl": "Polski", - - "_language_short": "Dwie litery na samym przelaczniku. Pelna nazwa wyzej jest tym, co mowi czytnik ekranu i co pokazuje dymek - kod jest tym, co sie miesci. Nazwa jezyka i jego kod sa zawsze w tym jezyku, nigdy tlumaczone.", - "chrome.language.short.en": "EN", - "chrome.language.short.pl": "PL", "chrome.footer.product": "Program", "chrome.footer.guides": "Poradniki", diff --git a/site/pages/404/pl.html b/site/pages/404/pl.html index 5c7059d8..41310792 100644 --- a/site/pages/404/pl.html +++ b/site/pages/404/pl.html @@ -1,6 +1,7 @@ +
diff --git a/site/pages/audit-windows-services/pl.html b/site/pages/audit-windows-services/pl.html index 0d2bbedc..9b1b17f0 100644 --- a/site/pages/audit-windows-services/pl.html +++ b/site/pages/audit-windows-services/pl.html @@ -1,6 +1,7 @@ +
diff --git a/site/pages/cli-reference/pl.html b/site/pages/cli-reference/pl.html index c180926b..6e7650c2 100644 --- a/site/pages/cli-reference/pl.html +++ b/site/pages/cli-reference/pl.html @@ -1,6 +1,7 @@ +
@@ -127,13 +128,13 @@

W skrypcie PowerShella

# nocne sprawdzenie, które rozgałęzia się po kodzie wyjścia bws snapshot diff C:\baselines\web01.json --live --exit-code > $null switch ($LASTEXITCODE) { - 0 { 'brak dryfu konfiguracji' } - 5 { 'konfiguracja się rozjechała' } - default { "brak czystej odpowiedzi, kod wyjścia $LASTEXITCODE" } + 0 { 'no configuration drift' } + 5 { 'the configuration drifted' } + default { "no clean answer, exit code $LASTEXITCODE" } }

W cmd i pliku wsadowym

rem druga komenda tylko wtedy, gdy pierwsza nie skończyła się czysto
-bws restart Spooler || echo restart nie skonczyl sie czysto
+bws restart Spooler || echo the restart did not end cleanly
 
 rem w pliku wsadowym z włączonym opóźnionym rozwijaniem ! potrzebuje daszka
 bws list --query "start:auto ^!status:running"
@@ -153,8 +154,8 @@

Przełączniki

Kody wyjścia

{{exit_code_count}} zakończeń, a każde z nich jest dla skryptu czymś innym. Dane idą na wyjście standardowe, a cała reszta na wyjście błędów, więc bws list --json | jq działa, a ostrzeżenie nigdy nie ląduje w JSON-ie.

-

Każdy dokument JSON jest czystym ASCII: litera spoza niego idzie jako sześcioznakowa ucieczka, którą każdy czytnik JSON zamienia z powrotem na literę, więc bws list --json > plik.json jest poprawnym UTF-8, a | ConvertFrom-Json czyta go dobrze przy każdej stronie kodowej konsoli. W tekście dla człowieka znak, który zamiast się wydrukować przestawiłby terminal - tabulator, koniec linii, sekwencja ucieczki, zmiana kierunku tekstu - jest pokazany jako swój numer, <U+001B>.

-

-- kończy przełączniki, więc usługę o nazwie zaczynającej się od łącznika da się nazwać: bws show -- -dziwna. Polecenia, które narzędzie podaje do skopiowania, są pisane dla PowerShella: nazwa ze spacją idzie w cudzysłowie, który cmd czyta tak samo, a nazwa, którą PowerShell rozwinąłby w cudzysłowie, na przykład MSSQL$SQLEXPRESS, w apostrofach.

+

Każdy dokument JSON jest czystym ASCII: litera spoza niego idzie jako sześcioznakowa ucieczka, którą każdy czytnik JSON zamienia z powrotem na literę, więc bws list --json > file.json jest poprawnym UTF-8, a | ConvertFrom-Json czyta go dobrze przy każdej stronie kodowej konsoli. W tekście dla człowieka znak, który zamiast się wydrukować przestawiłby terminal - tabulator, koniec linii, sekwencja ucieczki, zmiana kierunku tekstu - jest pokazany jako swój numer, <U+001B>.

+

-- kończy przełączniki, więc usługę o nazwie zaczynającej się od łącznika da się nazwać: bws show -- -odd. Polecenia, które narzędzie podaje do skopiowania, są pisane dla PowerShella: nazwa ze spacją idzie w cudzysłowie, który cmd czyta tak samo, a nazwa, którą PowerShell rozwinąłby w cudzysłowie, na przykład MSSQL$SQLEXPRESS, w apostrofach.

{{table:exit-codes}}

Ctrl+C ma trzy poziomy, a każdy mówi, ile kosztuje następny. Pierwszy przestaje iść naprzód i nadal oddaje to, co zabrał, drugi zostawia rzeczy jak są i nadal drukuje raport, trzeci kończy proces. Przebieg zatrzymany ręcznie kończy się kodem 4 nawet wtedy, gdy każdy krok i tak dotarł.

Zamknięcie okna konsoli liczy się jak pierwsze Ctrl+C. Windows daje programowi po nim pięć sekund, a ten w tym czasie przestaje iść naprzód i oddaje to, co zabrał. Wylogowanie i zamknięcie systemu kończą go bez ostrzeżenia, na które mógłby zareagować, a zerwana sesja zdalna może tak samo.

diff --git a/site/pages/download/en.html b/site/pages/download/en.html index aca110e9..a10e2818 100644 --- a/site/pages/download/en.html +++ b/site/pages/download/en.html @@ -42,6 +42,7 @@

Requirements

  • Administrator rights to change anything. Reading, searching, snapshots and diffs work without, with fewer entries visible and the tool saying so at the top rather than pretending the list is the whole machine.
  • The window keeps one file of its own - the layout you left the list in - under %APPDATA%\BetterWindowsServices. A snapshot goes where you tell bws snapshot create to put it, and into the current directory when you do not.

    +

    The program speaks English. The window reads a language file, languages\gui.<code>.json, placed beside it and chosen by the language of Windows, with no switch. A translation is therefore a contribution that can be added without rebuilding - CONTRIBUTING.md says how.

    diff --git a/site/pages/download/pl.html b/site/pages/download/pl.html index 0761af04..c10903af 100644 --- a/site/pages/download/pl.html +++ b/site/pages/download/pl.html @@ -1,6 +1,7 @@ +
    @@ -42,7 +43,7 @@

    Wymagania

  • Prawa administratora do zmieniania czegokolwiek. Czytanie, szukanie, snapshoty i porównania działają bez nich, z mniejszą liczbą widocznych wpisów - a program mówi to na górze, zamiast udawać, że lista jest całą maszyną.
  • Okno trzyma jeden własny plik - układ, w jakim zostawiłeś listę - pod %APPDATA%\BetterWindowsServices. Snapshot ląduje tam, gdzie każesz bws snapshot create, a bez wskazania w katalogu bieżącym.

    -

    Program mówi po angielsku, i to nie jest ostateczne: okno czyta plik językowy languages\gui.<kod>.json położony obok siebie, wybierany według języka systemu, bez żadnego przełącznika. Tłumaczenie jest więc wkładem, który da się dodać bez przebudowy - CONTRIBUTING.md mówi jak.

    +

    Program mówi po angielsku. Okno czyta plik językowy languages\gui.<kod>.json położony obok siebie, wybierany według języka systemu, bez żadnego przełącznika. Tłumaczenie jest więc wkładem, który da się dodać bez przebudowy - CONTRIBUTING.md mówi jak.

    diff --git a/site/pages/faq/en.html b/site/pages/faq/en.html index ce28f102..828e96f2 100644 --- a/site/pages/faq/en.html +++ b/site/pages/faq/en.html @@ -37,6 +37,10 @@

    Will it break my server?

    Is this a Microsoft product?

    No. {{product}} is an independent open source project and is not affiliated with, endorsed by or sponsored by Microsoft. Windows is a trademark of the Microsoft group of companies.

    +
    +

    Is the program in my language?

    +

    No - the program speaks English, which is why this site is translated. The window reads a language file, languages\gui.<code>.json, placed beside the executable and picks it by the language of Windows, with no switch, so a translation is a contribution that can be added without rebuilding the program. The names of the services in the list are whatever your Windows says they are - in the language your Windows is set up in.

    +

    Why is the window dark when my Windows is light?

    It is dark and it stays dark whatever Windows is set to, including a high contrast theme. That is checked rather than assumed - it keeps its own colours and stays readable, and it does not follow that setting.

    diff --git a/site/pages/faq/pl.html b/site/pages/faq/pl.html index 524563ef..a6008405 100644 --- a/site/pages/faq/pl.html +++ b/site/pages/faq/pl.html @@ -1,6 +1,7 @@ +
    @@ -39,7 +40,7 @@

    Czy to produkt Microsoftu?

    Czy program jest po polsku?

    -

    Nie - program mówi po angielsku, i ta strona jest w obu językach właśnie dlatego. Okno czyta plik językowy languages\gui.<kod>.json położony obok pliku wykonywalnego i wybiera go według języka systemu, bez przełącznika, więc tłumaczenie jest wkładem, który da się dodać bez przebudowy programu. Nazwy usług, które widzisz na liście, są tym, co mówi Twój Windows - to znaczy po polsku na polskim Windowsie.

    +

    Nie - program mówi po angielsku, i właśnie dlatego ta strona jest przetłumaczona. Okno czyta plik językowy languages\gui.<kod>.json położony obok pliku wykonywalnego i wybiera go według języka systemu, bez przełącznika, więc tłumaczenie jest wkładem, który da się dodać bez przebudowy programu. Nazwy usług, które widzisz na liście, są tym, co mówi Twój Windows - to znaczy po polsku na polskim Windowsie.

    Dlaczego okno jest ciemne, gdy mój Windows jest jasny?

    diff --git a/site/pages/home/en.html b/site/pages/home/en.html index f843f84b..9a640810 100644 --- a/site/pages/home/en.html +++ b/site/pages/home/en.html @@ -26,7 +26,7 @@

    Search your Windows services, preview every change, and see what drifted

    The window with five entries picked and the plan for starting them shown over the list: five steps, a note that two of them are already in that state, a bws start line for each entry, and the Start 5 entries button. -
    Entries picked, a plan shown before anything happens, and the bws line that asks for exactly the same thing.
    +
    Entries picked, a plan shown before anything happens, and the bws line that asks for exactly the same thing. The program speaks English - the window reads a language file placed beside it, so a translation is possible without a rebuild.
    diff --git a/site/pages/home/pl.html b/site/pages/home/pl.html index 12af5fe4..f93a4598 100644 --- a/site/pages/home/pl.html +++ b/site/pages/home/pl.html @@ -1,6 +1,7 @@ +
    diff --git a/site/pages/query-language/pl.html b/site/pages/query-language/pl.html index a7890ed0..4b8d1919 100644 --- a/site/pages/query-language/pl.html +++ b/site/pages/query-language/pl.html @@ -1,6 +1,7 @@ +
    diff --git a/site/pages/sc-exe-and-powershell/pl.html b/site/pages/sc-exe-and-powershell/pl.html index cadd93f0..8bc46606 100644 --- a/site/pages/sc-exe-and-powershell/pl.html +++ b/site/pages/sc-exe-and-powershell/pl.html @@ -1,6 +1,7 @@ +
    diff --git a/site/pages/services-msc-alternative/pl.html b/site/pages/services-msc-alternative/pl.html index 8d91cc9f..06c7cb4a 100644 --- a/site/pages/services-msc-alternative/pl.html +++ b/site/pages/services-msc-alternative/pl.html @@ -1,6 +1,7 @@ +
    diff --git a/site/pages/snapshots-and-drift/pl.html b/site/pages/snapshots-and-drift/pl.html index 9b5d1a53..88ddaef7 100644 --- a/site/pages/snapshots-and-drift/pl.html +++ b/site/pages/snapshots-and-drift/pl.html @@ -1,6 +1,7 @@ +
    diff --git a/site/pages/stop-a-service-that-will-not-stop/pl.html b/site/pages/stop-a-service-that-will-not-stop/pl.html index 4124538e..4ce05b93 100644 --- a/site/pages/stop-a-service-that-will-not-stop/pl.html +++ b/site/pages/stop-a-service-that-will-not-stop/pl.html @@ -1,6 +1,7 @@ +
    diff --git a/site/pages/what-should-be-running/pl.html b/site/pages/what-should-be-running/pl.html index 0f46760f..5c287f76 100644 --- a/site/pages/what-should-be-running/pl.html +++ b/site/pages/what-should-be-running/pl.html @@ -1,6 +1,7 @@ +
    diff --git a/site/site.json b/site/site.json index 8dbdf03b..12770688 100644 --- a/site/site.json +++ b/site/site.json @@ -6,9 +6,35 @@ "repo": "https://github.com/donislawdev/BetterWindowsServices", "support": "https://donislawdev.com/support/", - "_languages": "The first is the default and lives at the root. The others live under /{code}/.", + "_languages": "The languages that are PUBLISHED, in the order the switch lists them. The first is the default and lives at the root. The others live under /{code}/. A language that is in language_registry and not here is prepared and unreachable, which is how it is written and built before it goes live.", "languages": ["en", "pl"], + "_language_registry": "How each language is written, in one place. The key is the address code in lower case and the name of its files (site/i18n/{code}.json, site/pages/{id}/{code}.html). tag is what lang and hreflang carry, locale is og:locale, name is the language in its OWN alphabet and is never translated, short is what the switch shows for the language you are in. dir is written only when it is rtl. slugs is own when the language has a slug of its own for every page in each page.json, and english when its pages live at the default language's slugs - the choice for alphabets that cannot be an ASCII address without being transliterated.", + "language_registry": { + "en": { "tag": "en", "locale": "en_US", "name": "English", "short": "EN", "slugs": "own" }, + "pl": { "tag": "pl", "locale": "pl_PL", "name": "Polski", "short": "PL", "slugs": "own" }, + "cs": { "tag": "cs", "locale": "cs_CZ", "name": "Čeština", "short": "CS", "slugs": "own" }, + "de": { "tag": "de", "locale": "de_DE", "name": "Deutsch", "short": "DE", "slugs": "own" }, + "es": { "tag": "es", "locale": "es_ES", "name": "Español", "short": "ES", "slugs": "own" }, + "fr": { "tag": "fr", "locale": "fr_FR", "name": "Français", "short": "FR", "slugs": "own" }, + "hi": { "tag": "hi", "locale": "hi_IN", "name": "हिन्दी", "short": "HI", "slugs": "english" }, + "id": { "tag": "id", "locale": "id_ID", "name": "Bahasa Indonesia", "short": "ID", "slugs": "own" }, + "it": { "tag": "it", "locale": "it_IT", "name": "Italiano", "short": "IT", "slugs": "own" }, + "ja": { "tag": "ja", "locale": "ja_JP", "name": "日本語", "short": "JA", "slugs": "english" }, + "ko": { "tag": "ko", "locale": "ko_KR", "name": "한국어", "short": "KO", "slugs": "english" }, + "nl": { "tag": "nl", "locale": "nl_NL", "name": "Nederlands", "short": "NL", "slugs": "own" }, + "pt-br": { "tag": "pt-BR", "locale": "pt_BR", "name": "Português (Brasil)", "short": "PT-BR", "slugs": "own" }, + "ro": { "tag": "ro", "locale": "ro_RO", "name": "Română", "short": "RO", "slugs": "own" }, + "ru": { "tag": "ru", "locale": "ru_RU", "name": "Русский", "short": "RU", "slugs": "english" }, + "th": { "tag": "th", "locale": "th_TH", "name": "ไทย", "short": "TH", "slugs": "english" }, + "tr": { "tag": "tr", "locale": "tr_TR", "name": "Türkçe", "short": "TR", "slugs": "own" }, + "uk": { "tag": "uk", "locale": "uk_UA", "name": "Українська", "short": "UK", "slugs": "english" }, + "vi": { "tag": "vi", "locale": "vi_VN", "name": "Tiếng Việt", "short": "VI", "slugs": "own" }, + "zh-hans": { "tag": "zh-Hans", "locale": "zh_CN", "name": "简体中文", "short": "简体", "slugs": "english" }, + "zh-hant": { "tag": "zh-Hant", "locale": "zh_TW", "name": "繁體中文", "short": "繁體", "slugs": "english" }, + "ar": { "tag": "ar", "locale": "ar_AR", "name": "العربية", "short": "AR", "dir": "rtl", "slugs": "english" } + }, + "_theme_color": "The window's own background, src/Bws.Gui/Themes - the colour a browser paints its chrome with on a phone.", "theme_color": "#202020", diff --git a/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs b/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs index f7709993..c6c72de9 100644 --- a/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs +++ b/tests/Bws.Architecture.Tests/PublicSurfaceGuards.cs @@ -165,12 +165,12 @@ public void No_shape_that_belongs_to_a_person_is_written_into_a_published_file() "one star character on the line asking for a star, the same line the owner's other " + "public repositories carry - the rest of the file is plain ASCII on purpose", - ["site/i18n/pl.json"] = - "the website's Polish chrome and the sentences beside its generated tables. The " - + "site speaks two languages by the owner's decision, and this is the ONE JSON file " - + "that holds words a visitor reads - everything else a visitor reads is in a .html " - + "fragment, which this sweep does not cover. Putting the Polish in page.json files " - + "instead would be a dozen more permissions on this list" + ["site/site.json"] = + "the names of the website's languages in their own alphabets, which the language " + + "switch lists - 'Deutsch' beside the same names in Cyrillic, Arabic, Thai and Han script. A name that is transliterated is " + + "not the name somebody looking for their language is looking for. The language " + + "files under site/i18n/ are covered by extension below, so this is the one other " + + "JSON file a visitor reads words from" }; [Fact] @@ -182,13 +182,16 @@ public void Nothing_outside_plain_ascii_appears_without_a_reason_on_the_list() foreach (var file in Published()) { // The website's own pages are the exception, and by extension rather than by name. - // The site is published in Polish as well as English on the owner's decision, so - // every one of its fragments holds another alphabet on purpose - a permission per - // file would be two dozen entries saying the same sentence, and a list that long - // stops being read. The private-name sweep below still covers them, and that is - // the check these files actually need. + // The site is published in twenty-two languages on the owner's decision, so every + // one of its fragments holds another alphabet on purpose - a permission per file + // would be two hundred and fifty entries saying the same sentence, and a list that + // long stops being read. The language files under site/i18n are the same case in + // JSON: one per language, and the sentence is the same for each. The private-name + // sweep below still covers all of them, and that is the check these files need. + var relativePath = Path.GetRelativePath(root, file).Replace('\\', '/'); if (file.EndsWith(".html", StringComparison.OrdinalIgnoreCase) - || file.EndsWith(".css", StringComparison.OrdinalIgnoreCase)) + || file.EndsWith(".css", StringComparison.OrdinalIgnoreCase) + || relativePath.StartsWith("site/i18n/", StringComparison.OrdinalIgnoreCase)) { continue; } @@ -200,11 +203,9 @@ public void Nothing_outside_plain_ascii_appears_without_a_reason_on_the_list() continue; } - var relative = Path.GetRelativePath(root, file).Replace('\\', '/'); - - if (!MayHoldOtherAlphabets.ContainsKey(relative)) + if (!MayHoldOtherAlphabets.ContainsKey(relativePath)) { - unexplained.Add(" " + relative); + unexplained.Add(" " + relativePath); } } diff --git a/tests/Bws.Site.Tests/GeneratorTests.cs b/tests/Bws.Site.Tests/GeneratorTests.cs index c99400b4..73159ec6 100644 --- a/tests/Bws.Site.Tests/GeneratorTests.cs +++ b/tests/Bws.Site.Tests/GeneratorTests.cs @@ -54,7 +54,12 @@ public void Every_page_is_written_in_every_language() var pages = Directory.EnumerateDirectories(Path.Combine(_copy, "site", "pages")).Count(); var written = Directory.EnumerateFiles(_output, "*.html", SearchOption.AllDirectories).Count(); - Assert.Equal(pages * 2, written); + // The number of languages is read from the file that says it, not written here: this + // assertion used to say "two" and would have had to be edited for every language added. + using var config = JsonDocument.Parse(File.ReadAllText(Path.Combine(_copy, "site", "site.json"))); + var languages = config.RootElement.GetProperty("languages").GetArrayLength(); + + Assert.Equal(pages * languages, written); } [Fact] @@ -354,6 +359,185 @@ public void No_built_page_loads_anything_from_another_host() } } + [Fact] + public void A_translation_with_a_different_shape_from_the_original_is_reported() + { + // A class is the cheapest damage that is still a difference in structure: the page + // looks the same to a reader and is not the page the original is. + DamagePage("faq", "pl", "
    ", "
    "); + + Assert.Contains(Build(), problem => problem.Contains("parity: faq/pl.html does not have the shape", StringComparison.Ordinal)); + } + + [Fact] + public void A_translation_that_changes_a_command_is_reported() + { + DamagePage("faq", "pl", "start:auto !status:running", "start:auto !status:runing"); + + Assert.Contains(Build(), problem => problem.Contains("parity: faq/pl.html has different code", StringComparison.Ordinal)); + } + + [Fact] + public void A_translation_that_loses_a_token_is_reported() + { + DamagePage("home", "pl", "{{releases}}", "https://github.com"); + + Assert.Contains(Build(), problem => problem.Contains("parity: home/pl.html uses different tokens", StringComparison.Ordinal)); + } + + [Fact] + public void A_translation_without_a_stamp_is_a_problem() + { + DamagePage("home", "pl", StampLine("home", "pl"), ""); + + Assert.Contains(Build(), problem => problem.Contains("freshness: home/pl.html has no translated-from stamp", StringComparison.Ordinal)); + } + + /// + /// The owner's decision of 2026-10-21 in one test: English moves on, the translation is + /// LISTED and the build is still clean - and the same list is a failure when asked for. + /// + [Fact] + public void A_translation_older_than_the_english_is_listed_and_fails_only_when_asked() + { + var english = Path.Combine(_copy, "site", "pages", "home", "en.html"); + File.AppendAllText(english, ""); + + var plain = new Build(_copy, _output); + plain.Run(); + Assert.Empty(plain.Problems); + Assert.Contains(plain.Stale, line => line.Contains("home/pl.html", StringComparison.Ordinal)); + + var strict = new Build(_copy, _output, requireFresh: true); + strict.Run(); + Assert.Contains(strict.Problems, problem => problem.Contains("freshness: home/pl.html", StringComparison.Ordinal)); + } + + [Fact] + public void Stamping_a_language_makes_it_fresh_and_a_second_stamp_changes_nothing() + { + File.AppendAllText(Path.Combine(_copy, "site", "pages", "home", "en.html"), ""); + + var site = SiteConfig.Load(_copy); + var problems = new Problems(); + var pages = Pages.LoadDefinitions(_copy, site, problems); + + var first = Freshness.Stamp(_copy, site, "pl", pages, out _); + var second = Freshness.Stamp(_copy, site, "pl", pages, out _); + + var build = new Build(_copy, _output, requireFresh: true); + build.Run(); + + Assert.Empty(problems.Found); + // One file: only the English front page moved, so only its Polish was stamped again. + // The other eleven pages and the language file already carried the right stamp. + Assert.Equal(1, first); + Assert.Equal(0, second); + Assert.Empty(build.Problems); + } + + /// + /// The working tree on the owner's machine is CRLF and the repository is LF. A hash that saw + /// the difference would call every translation stale on one of the two, and the list would + /// be wrong in a way nobody could act on. + /// + [Fact] + public void A_difference_in_line_endings_alone_does_not_make_a_translation_stale() + { + var english = Path.Combine(_copy, "site", "pages", "home", "en.html"); + var text = File.ReadAllText(english).Replace("\r\n", "\n", StringComparison.Ordinal); + File.WriteAllText(english, text.Replace("\n", "\r\n", StringComparison.Ordinal)); + + var build = new Build(_copy, _output); + build.Run(); + + Assert.Empty(build.Problems); + Assert.Empty(build.Stale); + } + + [Fact] + public void A_file_for_a_language_the_registry_does_not_list_is_reported() + { + File.Copy( + Path.Combine(_copy, "site", "pages", "faq", "pl.html"), + Path.Combine(_copy, "site", "pages", "faq", "xx.html")); + + Assert.Contains(Build(), problem => problem.Contains("faq/xx.html belongs to no language", StringComparison.Ordinal)); + } + + [Fact] + public void A_published_language_with_no_registry_entry_stops_the_build_with_a_reason() + { + EditSite(text => text.Replace("\"languages\": [\"en\", \"pl\"]", "\"languages\": [\"en\", \"pl\", \"xx\"]", StringComparison.Ordinal)); + + Assert.Contains(Build(), problem => problem.Contains("'xx' is published under languages and has no entry", StringComparison.Ordinal)); + } + + [Fact] + public void A_direction_that_is_neither_ltr_nor_rtl_is_reported() + { + EditSite(text => text.Replace("\"dir\": \"rtl\"", "\"dir\": \"sideways\"", StringComparison.Ordinal)); + + Assert.Contains(Build(), problem => problem.Contains("sideways", StringComparison.Ordinal)); + } + + [Fact] + public void A_slug_for_a_language_whose_slugs_are_english_is_reported() + { + EditSite(text => text.Replace("\"languages\": [\"en\", \"pl\"]", "\"languages\": [\"en\", \"pl\", \"ar\"]", StringComparison.Ordinal)); + var path = Path.Combine(_copy, "site", "pages", "faq", "page.json"); + File.WriteAllText(path, File.ReadAllText(path).Replace("\"pl\": \"faq\"", "\"pl\": \"faq\", \"ar\": \"faq\"", StringComparison.Ordinal)); + + Assert.Contains(Build(), problem => problem.Contains("has a slug for 'ar', whose registry entry says slugs are english", StringComparison.Ordinal)); + } + + /// + /// What a right-to-left language gets, through the real generator. There is no Arabic in the + /// repository yet when this is written, so the language is made out of the Polish files - + /// the same shape, the same stamps, the Arabic registry entry - which is exactly enough for + /// the generator to have nothing to complain about and a page to be written for it. + /// + [Fact] + public void A_right_to_left_language_is_written_with_its_direction_and_listed_in_the_switch() + { + EditSite(text => text.Replace("\"languages\": [\"en\", \"pl\"]", "\"languages\": [\"en\", \"pl\", \"ar\"]", StringComparison.Ordinal)); + File.Copy(Path.Combine(_copy, "site", "i18n", "pl.json"), Path.Combine(_copy, "site", "i18n", "ar.json")); + foreach (var folder in Directory.EnumerateDirectories(Path.Combine(_copy, "site", "pages"))) + { + File.Copy(Path.Combine(folder, "pl.html"), Path.Combine(folder, "ar.html")); + } + + var problems = Build(); + + var arabic = File.ReadAllText(Path.Combine(_output, "ar", "faq", "index.html")); + var english = File.ReadAllText(Path.Combine(_output, "faq", "index.html")); + + Assert.Empty(problems); + Assert.Contains("", arabic, StringComparison.Ordinal); + Assert.Contains("", english, StringComparison.Ordinal); + Assert.Contains("hreflang=\"ar\" href=\"https://betterwindowsservices.donislawdev.com/ar/faq/\"", english, StringComparison.Ordinal); + Assert.Contains("\u0627\u0644\u0639\u0631\u0628\u064A\u0629", english, StringComparison.Ordinal); + } + + [Fact] + public void A_link_written_as_a_url_token_resolves_and_a_mistyped_one_is_reported() + { + DamagePage("faq", "pl", "href=\"/pl/sc-exe-i-powershell/\"", "href=\"{{url:sc-exe-and-powershell}}\""); + Assert.Empty(Build()); + + // Resolved in the language of the PAGE THAT WRITES IT. The address of the English page + // exists too, so the link check alone would pass a token that resolved to the wrong one. + // The Polish address is also in this page's navigation and footer, so asserting that it + // is present would pass either way - what only a wrong resolution can put on a Polish + // page is the English address. + var polish = File.ReadAllText(Path.Combine(_output, "pl", "faq", "index.html")); + Assert.Contains("href=\"/pl/sc-exe-i-powershell/\"", polish, StringComparison.Ordinal); + Assert.DoesNotContain("href=\"/sc-exe-and-powershell/\"", polish, StringComparison.Ordinal); + + DamagePage("faq", "pl", "{{url:sc-exe-and-powershell}}", "{{url:sc-exe-and-powershel}}"); + Assert.Contains(Build(), problem => problem.Contains("no page has the id 'sc-exe-and-powershel'", StringComparison.Ordinal)); + } + private IReadOnlyList Build() { var build = new Build(_copy, _output); @@ -361,6 +545,30 @@ private IReadOnlyList Build() return build.Problems; } + private void EditSite(Func edit) + { + var path = Path.Combine(_copy, "site", "site.json"); + var before = File.ReadAllText(path); + var after = edit(before); + + // Same rule as the page damage: an edit that changed nothing would let the test pass + // against a generator that checks nothing. + Assert.NotEqual(before, after); + File.WriteAllText(path, after); + } + + private string StampLine(string id, string language) + { + var text = File.ReadAllText(Path.Combine(_copy, "site", "pages", id, language + ".html")); + var match = System.Text.RegularExpressions.Regex.Match( + text, + "", + System.Text.RegularExpressions.RegexOptions.None, + TimeSpan.FromSeconds(1)); + Assert.True(match.Success, $"{id}/{language}.html carries no stamp to remove"); + return match.Value; + } + /// /// Breaks one thing in one page fragment. /// diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index b020263d..4454cbdb 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -6809,6 +6809,112 @@ $mutations = @( To = ' if (!Uri.TryCreate(target, UriKind.Absolute, out var uri) && false)' Test = 'GeneratorTests.A_malformed_address_is_reported_rather_than_thrown' } + @{ + # The shape of a translation against its original, added 2026-10-21 with the languages. The + # mutation makes the first difference unreachable: FirstDifference answers -1 or a position + # from zero, so below -1 is never true and a translation that dropped a paragraph passes. + Name = 'site: a translation with a different shape from the original is reported' + File = 'site\Bws.Site\Parity.cs' + From = ' if (firstDifference >= 0)' + To = ' if (firstDifference < -1)' + Test = 'GeneratorTests.A_translation_with_a_different_shape_from_the_original_is_reported' + } + @{ + # A command the reader pastes into a shell is the one thing a translation must not change. + Name = 'site: a translation that changes a command is reported' + File = 'site\Bws.Site\Parity.cs' + From = ' if (missing.Count > 0 || extra.Count > 0)' + To = ' if (missing.Count > 99 || extra.Count > 99)' + Test = 'GeneratorTests.A_translation_that_changes_a_command_is_reported' + } + @{ + # A page that loses a token loses the link it stood for. The shape check also fails on that + # page, and the assertion is on the tokens message, which only this check writes. + Name = 'site: a translation that loses a token is reported' + File = 'site\Bws.Site\Parity.cs' + From = ' if (tokenMissing.Count > 0 || tokenExtra.Count > 0)' + To = ' if (tokenMissing.Count > 99 || tokenExtra.Count > 99)' + Test = 'GeneratorTests.A_translation_that_loses_a_token_is_reported' + } + @{ + # The owner decided on 2026-10-21 that an old translation is LISTED, so the comparison is + # what puts it on the list. Inverted, the list holds the fresh ones and not the old one. + Name = 'site: a translation older than the english is listed' + File = 'site\Bws.Site\Freshness.cs' + From = ' else if (!string.Equals(stamp, current, StringComparison.Ordinal))' + To = ' else if (string.Equals(stamp, current, StringComparison.Ordinal))' + Test = 'GeneratorTests.A_translation_older_than_the_english_is_listed_and_fails_only_when_asked' + } + @{ + # A translation that cannot say what it follows can never be called old, so a missing stamp + # is a failure. Inverted, a missing stamp is read as a stale one and nothing fails. + Name = 'site: a translation without a stamp is a problem' + File = 'site\Bws.Site\Freshness.cs' + From = ' if (stamp is null)' + To = ' if (stamp is not null)' + Test = 'GeneratorTests.A_translation_without_a_stamp_is_a_problem' + } + @{ + # The working tree is CRLF and the repository LF. A hash that saw the difference would call + # every translation old on one of the two. + Name = 'site: line endings alone do not make a translation old' + File = 'site\Bws.Site\Freshness.cs' + From = ' var normal = text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();' + To = ' var normal = text.Trim();' + Test = 'GeneratorTests.A_difference_in_line_endings_alone_does_not_make_a_translation_stale' + } + @{ + # A file named after a code that is nowhere is a typo that would sit built by nothing. The + # sixteen-space line is the one in the pages loop - the language files have a twelve-space one. + Name = 'site: a page file for an unknown language is reported' + File = 'site\Bws.Site\Freshness.cs' + From = ' if (!site.LanguageRegistry.ContainsKey(code))' + To = ' if (site.LanguageRegistry.ContainsKey(code))' + Test = 'GeneratorTests.A_file_for_a_language_the_registry_does_not_list_is_reported' + } + @{ + # Every page asks the registry for its language, so a published language with no entry has to + # stop the build with a reason and not with a missing-key exception. + Name = 'site: a published language with no registry entry is reported' + File = 'site\Bws.Site\SiteConfig.cs' + From = ' foreach (var code in Languages.Where(code => !LanguageRegistry.ContainsKey(code)))' + To = ' foreach (var code in Languages.Where(code => LanguageRegistry.ContainsKey(code)))' + Test = 'GeneratorTests.A_published_language_with_no_registry_entry_stops_the_build_with_a_reason' + } + @{ + Name = 'site: a direction that is neither ltr nor rtl is reported' + File = 'site\Bws.Site\SiteConfig.cs' + From = ' if (info.Dir is not ("ltr" or "rtl"))' + To = ' if (info.Dir is not ("ltr" or "rtl" or "sideways"))' + Test = 'GeneratorTests.A_direction_that_is_neither_ltr_nor_rtl_is_reported' + } + @{ + # A language the registry sets to english slugs would carry a slug nothing reads. Inverted, + # the rule is applied to the wrong half of the languages. + Name = 'site: a slug for a language with english slugs is reported' + File = 'site\Bws.Site\Pages.cs' + From = ' if (info.Slugs != "own")' + To = ' if (info.Slugs == "own")' + Test = 'GeneratorTests.A_slug_for_a_language_whose_slugs_are_english_is_reported' + } + @{ + # dir is written only for a right-to-left language. Inverted, English carries dir=ltr and + # Arabic carries nothing, which is the page the test builds and reads back. + Name = 'site: a right to left language is written with its direction' + File = 'site\Bws.Site\Renderer.cs' + From = ' var direction = info.Dir == "ltr" ? "" : $" dir=\"{info.Dir}\"";' + To = ' var direction = info.Dir != "ltr" ? "" : $" dir=\"{info.Dir}\"";' + Test = 'GeneratorTests.A_right_to_left_language_is_written_with_its_direction_and_listed_in_the_switch' + } + @{ + # {{url:id}} is the address in the language of the page that writes it. The English page + # exists as well, so only the built output tells the two apart. + Name = 'site: a url token resolves in the language of the page' + File = 'site\Bws.Site\Tokens.cs' + From = ' return target.Url(language, _site);' + To = ' return target.Url(_site.DefaultLanguage, _site);' + Test = 'GeneratorTests.A_link_written_as_a_url_token_resolves_and_a_mistyped_one_is_reported' + } @{ # RUNG FIVE ITSELF. Without this check the plan is built, shown, and carried out until its From e60233369cdbfd095d94221d2b39565cd022b35a Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 21 Oct 2026 10:22:33 +0200 Subject: [PATCH 2/5] Make the generator tests independent of which languages are published The tests edited the published list by matching its exact text and borrowed Arabic for the right-to-left case, so each language added would have broken them. They now append to whatever the list holds and invent the languages they need. Co-Authored-By: Claude Sonnet 5.5 --- tests/Bws.Site.Tests/GeneratorTests.cs | 63 ++++++++++++++++++-------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/tests/Bws.Site.Tests/GeneratorTests.cs b/tests/Bws.Site.Tests/GeneratorTests.cs index 73159ec6..c29c8763 100644 --- a/tests/Bws.Site.Tests/GeneratorTests.cs +++ b/tests/Bws.Site.Tests/GeneratorTests.cs @@ -422,16 +422,20 @@ public void Stamping_a_language_makes_it_fresh_and_a_second_stamp_changes_nothin var problems = new Problems(); var pages = Pages.LoadDefinitions(_copy, site, problems); - var first = Freshness.Stamp(_copy, site, "pl", pages, out _); - var second = Freshness.Stamp(_copy, site, "pl", pages, out _); + var first = Freshness.Stamp(_copy, site, "all", pages, out _); + var second = Freshness.Stamp(_copy, site, "all", pages, out _); var build = new Build(_copy, _output, requireFresh: true); build.Run(); + // One file per translation: only the English front page moved, so only its translations + // were stamped again. The other eleven pages and the language files already carried the + // right stamp. The number of translations is counted, not written, so adding a language + // does not break this. + var translations = Directory.EnumerateFiles(Path.Combine(_copy, "site", "pages", "home"), "*.html").Count() - 1; + Assert.Empty(problems.Found); - // One file: only the English front page moved, so only its Polish was stamped again. - // The other eleven pages and the language file already carried the right stamp. - Assert.Equal(1, first); + Assert.Equal(translations, first); Assert.Equal(0, second); Assert.Empty(build.Problems); } @@ -468,7 +472,7 @@ public void A_file_for_a_language_the_registry_does_not_list_is_reported() [Fact] public void A_published_language_with_no_registry_entry_stops_the_build_with_a_reason() { - EditSite(text => text.Replace("\"languages\": [\"en\", \"pl\"]", "\"languages\": [\"en\", \"pl\", \"xx\"]", StringComparison.Ordinal)); + Publish("xx"); Assert.Contains(Build(), problem => problem.Contains("'xx' is published under languages and has no entry", StringComparison.Ordinal)); } @@ -484,39 +488,42 @@ public void A_direction_that_is_neither_ltr_nor_rtl_is_reported() [Fact] public void A_slug_for_a_language_whose_slugs_are_english_is_reported() { - EditSite(text => text.Replace("\"languages\": [\"en\", \"pl\"]", "\"languages\": [\"en\", \"pl\", \"ar\"]", StringComparison.Ordinal)); + Register("xe", "ltr", "english"); + Publish("xe"); var path = Path.Combine(_copy, "site", "pages", "faq", "page.json"); - File.WriteAllText(path, File.ReadAllText(path).Replace("\"pl\": \"faq\"", "\"pl\": \"faq\", \"ar\": \"faq\"", StringComparison.Ordinal)); + File.WriteAllText(path, File.ReadAllText(path).Replace("\"pl\": \"faq\"", "\"pl\": \"faq\", \"xe\": \"faq\"", StringComparison.Ordinal)); - Assert.Contains(Build(), problem => problem.Contains("has a slug for 'ar', whose registry entry says slugs are english", StringComparison.Ordinal)); + Assert.Contains(Build(), problem => problem.Contains("has a slug for 'xe', whose registry entry says slugs are english", StringComparison.Ordinal)); } /// - /// What a right-to-left language gets, through the real generator. There is no Arabic in the - /// repository yet when this is written, so the language is made out of the Polish files - - /// the same shape, the same stamps, the Arabic registry entry - which is exactly enough for - /// the generator to have nothing to complain about and a page to be written for it. + /// What a right-to-left language gets, through the real generator. The language is invented + /// for the test and made out of the Polish files - the same shape, the same stamps, an + /// invented registry entry - which is exactly enough for the generator to have nothing to + /// complain about and a page to be written for it. It is invented, not Arabic, so the test + /// does not change the day Arabic is published. /// [Fact] public void A_right_to_left_language_is_written_with_its_direction_and_listed_in_the_switch() { - EditSite(text => text.Replace("\"languages\": [\"en\", \"pl\"]", "\"languages\": [\"en\", \"pl\", \"ar\"]", StringComparison.Ordinal)); - File.Copy(Path.Combine(_copy, "site", "i18n", "pl.json"), Path.Combine(_copy, "site", "i18n", "ar.json")); + Register("xr", "rtl", "english"); + Publish("xr"); + File.Copy(Path.Combine(_copy, "site", "i18n", "pl.json"), Path.Combine(_copy, "site", "i18n", "xr.json")); foreach (var folder in Directory.EnumerateDirectories(Path.Combine(_copy, "site", "pages"))) { - File.Copy(Path.Combine(folder, "pl.html"), Path.Combine(folder, "ar.html")); + File.Copy(Path.Combine(folder, "pl.html"), Path.Combine(folder, "xr.html")); } var problems = Build(); - var arabic = File.ReadAllText(Path.Combine(_output, "ar", "faq", "index.html")); + var rtl = File.ReadAllText(Path.Combine(_output, "xr", "faq", "index.html")); var english = File.ReadAllText(Path.Combine(_output, "faq", "index.html")); Assert.Empty(problems); - Assert.Contains("", arabic, StringComparison.Ordinal); + Assert.Contains("", rtl, StringComparison.Ordinal); Assert.Contains("", english, StringComparison.Ordinal); - Assert.Contains("hreflang=\"ar\" href=\"https://betterwindowsservices.donislawdev.com/ar/faq/\"", english, StringComparison.Ordinal); - Assert.Contains("\u0627\u0644\u0639\u0631\u0628\u064A\u0629", english, StringComparison.Ordinal); + Assert.Contains("hreflang=\"xr\" href=\"https://betterwindowsservices.donislawdev.com/xr/faq/\"", english, StringComparison.Ordinal); + Assert.Contains(">Test language<", english, StringComparison.Ordinal); } [Fact] @@ -545,6 +552,22 @@ private IReadOnlyList Build() return build.Problems; } + /// Adds a language to the published list, whatever the list holds now. + private void Publish(string code) => + EditSite(text => System.Text.RegularExpressions.Regex.Replace( + text, + "(\"languages\"\\s*:\\s*\\[[^\\]]*)\\]", + "$1, \"" + code + "\"]", + System.Text.RegularExpressions.RegexOptions.None, + TimeSpan.FromSeconds(1))); + + /// Adds an invented language to the registry, so a test never depends on a real one being published or not. + private void Register(string code, string dir, string slugs) => + EditSite(text => text.Replace( + "\"language_registry\": {", + "\"language_registry\": {\n \"" + code + "\": { \"tag\": \"" + code + "\", \"locale\": \"" + code + "_XX\", \"name\": \"Test language\", \"short\": \"" + code.ToUpperInvariant() + "\", \"dir\": \"" + dir + "\", \"slugs\": \"" + slugs + "\" },", + StringComparison.Ordinal)); + private void EditSite(Func edit) { var path = Path.Combine(_copy, "site", "site.json"); From 79b156b4c4d562daeea466bb14ed523bb2010821 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 21 Oct 2026 10:30:11 +0200 Subject: [PATCH 3/5] Turn a no-break space entity into the character in the questions' structured data French writes a no-break space before a colon and a question mark, as an entity. The structured data is JSON a machine reads, and without this it carried the letters of the entity. Co-Authored-By: Claude Sonnet 5.5 --- site/Bws.Site/StructuredData.cs | 5 +++++ tests/Bws.Site.Tests/GeneratorTests.cs | 21 +++++++++++++++++++++ tests/mutation/entries.ps1 | 9 +++++++++ 3 files changed, 35 insertions(+) diff --git a/site/Bws.Site/StructuredData.cs b/site/Bws.Site/StructuredData.cs index b9eee1a7..fea14cab 100644 --- a/site/Bws.Site/StructuredData.cs +++ b/site/Bws.Site/StructuredData.cs @@ -123,6 +123,11 @@ private static string Plain(string html) .Replace(">", ">", StringComparison.Ordinal) .Replace(""", "\"", StringComparison.Ordinal) .Replace("·", "\u00B7", StringComparison.Ordinal) + + // French puts a no-break space before a colon or a question mark, and writes it as an + // entity. Without this line the questions' markup said "mon serveur ?" to a search + // engine, which reads the letters. + .Replace(" ", "\u00A0", StringComparison.Ordinal) .Replace("&", "&", StringComparison.Ordinal); } } diff --git a/tests/Bws.Site.Tests/GeneratorTests.cs b/tests/Bws.Site.Tests/GeneratorTests.cs index c29c8763..37aa5dcf 100644 --- a/tests/Bws.Site.Tests/GeneratorTests.cs +++ b/tests/Bws.Site.Tests/GeneratorTests.cs @@ -459,6 +459,27 @@ public void A_difference_in_line_endings_alone_does_not_make_a_translation_stale Assert.Empty(build.Stale); } + /// + /// French writes a no-break space before a colon and a question mark, as an entity. The + /// structured data is JSON read by a machine, so the entity has to be turned into the + /// character there - what it says to a search engine otherwise is the letters. + /// + [Fact] + public void A_no_break_space_in_a_question_reaches_the_structured_data_as_the_character() + { + DamagePage("faq", "en", "Does it need the internet?", "Does it need the internet ?"); + + Assert.Empty(Build()); + + var page = File.ReadAllText(Path.Combine(_output, "faq", "index.html")); + var start = page.IndexOf("application/ld+json", StringComparison.Ordinal); + Assert.True(start >= 0, "the questions page should carry structured data"); + + var data = page[start..page.IndexOf("", start, StringComparison.Ordinal)]; + Assert.Contains("the internet\u00A0?", data, StringComparison.Ordinal); + Assert.DoesNotContain(" ", data, StringComparison.Ordinal); + } + [Fact] public void A_file_for_a_language_the_registry_does_not_list_is_reported() { diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index 4454cbdb..6e040ab3 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -6915,6 +6915,15 @@ $mutations = @( To = ' return target.Url(_site.DefaultLanguage, _site);' Test = 'GeneratorTests.A_link_written_as_a_url_token_resolves_and_a_mistyped_one_is_reported' } + @{ + # French writes a no-break space before a colon and a question mark, as an entity, and the + # structured data is JSON read by a machine - without this line it carries the letters. + Name = 'site: a no-break space in a question reaches the structured data as the character' + File = 'site\Bws.Site\StructuredData.cs' + From = ' .Replace(" ", "\u00A0", StringComparison.Ordinal)' + To = ' .Replace(" ", " ", StringComparison.Ordinal)' + Test = 'GeneratorTests.A_no_break_space_in_a_question_reaches_the_structured_data_as_the_character' + } @{ # RUNG FIVE ITSELF. Without this check the plan is built, shown, and carried out until its From 7365a2466ad0170bbfdbcb7fdb6c124f6ada3e45 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 21 Oct 2026 10:37:11 +0200 Subject: [PATCH 4/5] Let a button label wrap on a phone A label is nowrap so a short English one stays on a line, but a label of forty-odd characters in another language is wider than a phone and pushed the whole page sideways - Spanish at 375 px, six pixels over. The button is full width there already, so wrapping costs a line of height. Co-Authored-By: Claude Sonnet 5.5 --- site/assets/style.css | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/site/assets/style.css b/site/assets/style.css index 9218c23c..8ede5803 100644 --- a/site/assets/style.css +++ b/site/assets/style.css @@ -799,7 +799,14 @@ figcaption { @media (max-width: 36rem) { :root { --gutter: var(--s-4); } .hero { padding-block: var(--s-7); } - .btn { width: 100%; } + /* + A button label may wrap here. It is `nowrap` everywhere else so a short English label stays + on one line, but on a phone a label of forty-odd characters in another language is wider than + the screen and pushed the whole page sideways - Spanish "Requisitos y compilacion desde el + codigo fuente" at 375 px, six pixels over. The button is full width already, so wrapping the + text costs one line of height and nothing else. + */ + .btn { width: 100%; white-space: normal; text-align: center; } th, td { padding: var(--s-2) var(--s-3); } /* A group of examples stacks on a phone - the query on a line of its own, then what it finds - From 2c76ddf9b42296e93ad143e28626da0386fc17a5 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 21 Oct 2026 10:53:30 +0200 Subject: [PATCH 5/5] Split the generator tests so each file stays under the length ceiling The tests for the languages took GeneratorTests over the ceiling the size ratchet holds test files to. The seam is the one the file already had: the sandbox a test breaks something in, the older tests, and the tests for languages. The mutation entries follow the tests that moved. Co-Authored-By: Claude Sonnet 5.5 --- tests/Bws.Site.Tests/GeneratorTests.cs | 390 ++----------------------- tests/Bws.Site.Tests/LanguageTests.cs | 220 ++++++++++++++ tests/Bws.Site.Tests/SiteTestBase.cs | 155 ++++++++++ tests/mutation/entries.ps1 | 26 +- 4 files changed, 410 insertions(+), 381 deletions(-) create mode 100644 tests/Bws.Site.Tests/LanguageTests.cs create mode 100644 tests/Bws.Site.Tests/SiteTestBase.cs diff --git a/tests/Bws.Site.Tests/GeneratorTests.cs b/tests/Bws.Site.Tests/GeneratorTests.cs index 37aa5dcf..76f05fa7 100644 --- a/tests/Bws.Site.Tests/GeneratorTests.cs +++ b/tests/Bws.Site.Tests/GeneratorTests.cs @@ -15,29 +15,11 @@ namespace Bws.Site.Tests; /// Without it, every other test here could pass against a generator that reports problems /// unconditionally. /// -public sealed class GeneratorTests : IDisposable +public sealed class GeneratorTests : SiteTestBase { // The session on the front page, which the poster tests plant a video in front of. private const string SessionImage = " candidate.Contains("\"exit.ok\"", StringComparison.Ordinal)); File.WriteAllText(path, text.Replace(line + "\n", "", StringComparison.Ordinal)); @@ -185,7 +167,7 @@ public void A_sentence_about_an_exit_code_the_program_does_not_have_is_reported( { foreach (var language in new[] { "en", "pl" }) { - var path = Path.Combine(_copy, "site", "i18n", language + ".json"); + var path = Path.Combine(Copy, "site", "i18n", language + ".json"); File.WriteAllText( path, File.ReadAllText(path).Replace( @@ -200,14 +182,14 @@ public void A_sentence_about_an_exit_code_the_program_does_not_have_is_reported( [Fact] public void A_page_nothing_links_to_is_reported() { - foreach (var file in Directory.EnumerateFiles(Path.Combine(_copy, "site", "pages"), "*.html", SearchOption.AllDirectories)) + foreach (var file in Directory.EnumerateFiles(Path.Combine(Copy, "site", "pages"), "*.html", SearchOption.AllDirectories)) { File.WriteAllText(file, File.ReadAllText(file) .Replace("/sc-exe-and-powershell/", "/cli-reference/", StringComparison.Ordinal) .Replace("/pl/sc-exe-i-powershell/", "/pl/dokumentacja-cli/", StringComparison.Ordinal)); } - var definition = Path.Combine(_copy, "site", "pages", "sc-exe-and-powershell", "page.json"); + var definition = Path.Combine(Copy, "site", "pages", "sc-exe-and-powershell", "page.json"); File.WriteAllText(definition, File.ReadAllText(definition).Replace("\"footer\": \"guides\",", "", StringComparison.Ordinal)); Assert.Contains(Build(), problem => problem.Contains("sc-exe-and-powershell", StringComparison.Ordinal)); @@ -216,7 +198,7 @@ public void A_page_nothing_links_to_is_reported() [Fact] public void Two_pages_sharing_an_address_are_reported() { - var definition = Path.Combine(_copy, "site", "pages", "faq", "page.json"); + var definition = Path.Combine(Copy, "site", "pages", "faq", "page.json"); File.WriteAllText(definition, File.ReadAllText(definition).Replace("\"en\": \"faq\"", "\"en\": \"download\"", StringComparison.Ordinal)); Assert.Contains(Build(), problem => problem.Contains("share the address", StringComparison.Ordinal)); @@ -241,7 +223,7 @@ public void A_title_too_long_for_a_search_result_is_reported() [Fact] public void A_switch_missing_from_the_readme_table_is_reported() { - var path = Path.Combine(_copy, "README.md"); + var path = Path.Combine(Copy, "README.md"); var text = File.ReadAllText(path); var row = text.Split('\n').First(candidate => candidate.StartsWith("| `--dry-run`", StringComparison.Ordinal)); File.WriteAllText(path, text.Replace(row + "\n", "", StringComparison.Ordinal)); @@ -252,7 +234,7 @@ public void A_switch_missing_from_the_readme_table_is_reported() [Fact] public void An_exit_code_missing_from_the_readme_table_is_reported() { - var path = Path.Combine(_copy, "README.md"); + var path = Path.Combine(Copy, "README.md"); var text = File.ReadAllText(path); var row = text.Split('\n').First(candidate => candidate.StartsWith("| `5` |", StringComparison.Ordinal)); File.WriteAllText(path, text.Replace(row + "\n", "", StringComparison.Ordinal)); @@ -268,7 +250,7 @@ public void An_exit_code_missing_from_the_readme_table_is_reported() [Fact] public void A_question_page_with_no_questions_left_in_it_is_reported() { - var path = Path.Combine(_copy, "site", "pages", "faq", "en.html"); + var path = Path.Combine(Copy, "site", "pages", "faq", "en.html"); File.WriteAllText(path, File.ReadAllText(path).Replace("
    ", "
    ", StringComparison.Ordinal)); Assert.Contains(Build(), problem => problem.Contains("question page", StringComparison.Ordinal)); @@ -281,10 +263,10 @@ public void The_structured_data_on_the_two_pages_that_carry_it_is_valid_json() foreach (var (file, type) in new[] { - (Path.Combine(_output, "index.html"), "SoftwareApplication"), - (Path.Combine(_output, "faq", "index.html"), "FAQPage"), - (Path.Combine(_output, "pl", "index.html"), "SoftwareApplication"), - (Path.Combine(_output, "pl", "faq", "index.html"), "FAQPage"), + (Path.Combine(Output, "index.html"), "SoftwareApplication"), + (Path.Combine(Output, "faq", "index.html"), "FAQPage"), + (Path.Combine(Output, "pl", "index.html"), "SoftwareApplication"), + (Path.Combine(Output, "pl", "faq", "index.html"), "FAQPage"), }) { var text = File.ReadAllText(file); @@ -310,10 +292,10 @@ public void The_sitemap_names_every_page_it_wrote_and_nothing_else() { Build(); - var sitemap = File.ReadAllText(Path.Combine(_output, "sitemap.xml")); + var sitemap = File.ReadAllText(Path.Combine(Output, "sitemap.xml")); var written = Directory - .EnumerateFiles(_output, "*.html", SearchOption.AllDirectories) - .Select(file => "/" + Path.GetRelativePath(_output, file).Replace('\\', '/')) + .EnumerateFiles(Output, "*.html", SearchOption.AllDirectories) + .Select(file => "/" + Path.GetRelativePath(Output, file).Replace('\\', '/')) .Select(path => path.EndsWith("/index.html", StringComparison.Ordinal) ? path[..^"index.html".Length] : path) .ToList(); @@ -330,7 +312,7 @@ public void The_not_found_page_refuses_to_be_indexed() { Build(); - Assert.Contains("", File.ReadAllText(Path.Combine(_output, "404.html")), StringComparison.Ordinal); + Assert.Contains("", File.ReadAllText(Path.Combine(Output, "404.html")), StringComparison.Ordinal); } [Fact] @@ -338,7 +320,7 @@ public void The_custom_domain_is_written_where_pages_looks_for_it() { Build(); - Assert.Equal("betterwindowsservices.donislawdev.com\n", File.ReadAllText(Path.Combine(_output, "CNAME"))); + Assert.Equal("betterwindowsservices.donislawdev.com\n", File.ReadAllText(Path.Combine(Output, "CNAME"))); } /// @@ -351,339 +333,11 @@ public void No_built_page_loads_anything_from_another_host() { Build(); - foreach (var file in Directory.EnumerateFiles(_output, "*.html", SearchOption.AllDirectories)) + foreach (var file in Directory.EnumerateFiles(Output, "*.html", SearchOption.AllDirectories)) { var text = File.ReadAllText(file); Assert.DoesNotContain("src=\"http", text, StringComparison.Ordinal); Assert.DoesNotContain("", "
    "); - - Assert.Contains(Build(), problem => problem.Contains("parity: faq/pl.html does not have the shape", StringComparison.Ordinal)); - } - - [Fact] - public void A_translation_that_changes_a_command_is_reported() - { - DamagePage("faq", "pl", "start:auto !status:running", "start:auto !status:runing"); - - Assert.Contains(Build(), problem => problem.Contains("parity: faq/pl.html has different code", StringComparison.Ordinal)); - } - - [Fact] - public void A_translation_that_loses_a_token_is_reported() - { - DamagePage("home", "pl", "{{releases}}", "https://github.com"); - - Assert.Contains(Build(), problem => problem.Contains("parity: home/pl.html uses different tokens", StringComparison.Ordinal)); - } - - [Fact] - public void A_translation_without_a_stamp_is_a_problem() - { - DamagePage("home", "pl", StampLine("home", "pl"), ""); - - Assert.Contains(Build(), problem => problem.Contains("freshness: home/pl.html has no translated-from stamp", StringComparison.Ordinal)); - } - - /// - /// The owner's decision of 2026-10-21 in one test: English moves on, the translation is - /// LISTED and the build is still clean - and the same list is a failure when asked for. - /// - [Fact] - public void A_translation_older_than_the_english_is_listed_and_fails_only_when_asked() - { - var english = Path.Combine(_copy, "site", "pages", "home", "en.html"); - File.AppendAllText(english, ""); - - var plain = new Build(_copy, _output); - plain.Run(); - Assert.Empty(plain.Problems); - Assert.Contains(plain.Stale, line => line.Contains("home/pl.html", StringComparison.Ordinal)); - - var strict = new Build(_copy, _output, requireFresh: true); - strict.Run(); - Assert.Contains(strict.Problems, problem => problem.Contains("freshness: home/pl.html", StringComparison.Ordinal)); - } - - [Fact] - public void Stamping_a_language_makes_it_fresh_and_a_second_stamp_changes_nothing() - { - File.AppendAllText(Path.Combine(_copy, "site", "pages", "home", "en.html"), ""); - - var site = SiteConfig.Load(_copy); - var problems = new Problems(); - var pages = Pages.LoadDefinitions(_copy, site, problems); - - var first = Freshness.Stamp(_copy, site, "all", pages, out _); - var second = Freshness.Stamp(_copy, site, "all", pages, out _); - - var build = new Build(_copy, _output, requireFresh: true); - build.Run(); - - // One file per translation: only the English front page moved, so only its translations - // were stamped again. The other eleven pages and the language files already carried the - // right stamp. The number of translations is counted, not written, so adding a language - // does not break this. - var translations = Directory.EnumerateFiles(Path.Combine(_copy, "site", "pages", "home"), "*.html").Count() - 1; - - Assert.Empty(problems.Found); - Assert.Equal(translations, first); - Assert.Equal(0, second); - Assert.Empty(build.Problems); - } - - /// - /// The working tree on the owner's machine is CRLF and the repository is LF. A hash that saw - /// the difference would call every translation stale on one of the two, and the list would - /// be wrong in a way nobody could act on. - /// - [Fact] - public void A_difference_in_line_endings_alone_does_not_make_a_translation_stale() - { - var english = Path.Combine(_copy, "site", "pages", "home", "en.html"); - var text = File.ReadAllText(english).Replace("\r\n", "\n", StringComparison.Ordinal); - File.WriteAllText(english, text.Replace("\n", "\r\n", StringComparison.Ordinal)); - - var build = new Build(_copy, _output); - build.Run(); - - Assert.Empty(build.Problems); - Assert.Empty(build.Stale); - } - - /// - /// French writes a no-break space before a colon and a question mark, as an entity. The - /// structured data is JSON read by a machine, so the entity has to be turned into the - /// character there - what it says to a search engine otherwise is the letters. - /// - [Fact] - public void A_no_break_space_in_a_question_reaches_the_structured_data_as_the_character() - { - DamagePage("faq", "en", "Does it need the internet?", "Does it need the internet ?"); - - Assert.Empty(Build()); - - var page = File.ReadAllText(Path.Combine(_output, "faq", "index.html")); - var start = page.IndexOf("application/ld+json", StringComparison.Ordinal); - Assert.True(start >= 0, "the questions page should carry structured data"); - - var data = page[start..page.IndexOf("", start, StringComparison.Ordinal)]; - Assert.Contains("the internet\u00A0?", data, StringComparison.Ordinal); - Assert.DoesNotContain(" ", data, StringComparison.Ordinal); - } - - [Fact] - public void A_file_for_a_language_the_registry_does_not_list_is_reported() - { - File.Copy( - Path.Combine(_copy, "site", "pages", "faq", "pl.html"), - Path.Combine(_copy, "site", "pages", "faq", "xx.html")); - - Assert.Contains(Build(), problem => problem.Contains("faq/xx.html belongs to no language", StringComparison.Ordinal)); - } - - [Fact] - public void A_published_language_with_no_registry_entry_stops_the_build_with_a_reason() - { - Publish("xx"); - - Assert.Contains(Build(), problem => problem.Contains("'xx' is published under languages and has no entry", StringComparison.Ordinal)); - } - - [Fact] - public void A_direction_that_is_neither_ltr_nor_rtl_is_reported() - { - EditSite(text => text.Replace("\"dir\": \"rtl\"", "\"dir\": \"sideways\"", StringComparison.Ordinal)); - - Assert.Contains(Build(), problem => problem.Contains("sideways", StringComparison.Ordinal)); - } - - [Fact] - public void A_slug_for_a_language_whose_slugs_are_english_is_reported() - { - Register("xe", "ltr", "english"); - Publish("xe"); - var path = Path.Combine(_copy, "site", "pages", "faq", "page.json"); - File.WriteAllText(path, File.ReadAllText(path).Replace("\"pl\": \"faq\"", "\"pl\": \"faq\", \"xe\": \"faq\"", StringComparison.Ordinal)); - - Assert.Contains(Build(), problem => problem.Contains("has a slug for 'xe', whose registry entry says slugs are english", StringComparison.Ordinal)); - } - - /// - /// What a right-to-left language gets, through the real generator. The language is invented - /// for the test and made out of the Polish files - the same shape, the same stamps, an - /// invented registry entry - which is exactly enough for the generator to have nothing to - /// complain about and a page to be written for it. It is invented, not Arabic, so the test - /// does not change the day Arabic is published. - /// - [Fact] - public void A_right_to_left_language_is_written_with_its_direction_and_listed_in_the_switch() - { - Register("xr", "rtl", "english"); - Publish("xr"); - File.Copy(Path.Combine(_copy, "site", "i18n", "pl.json"), Path.Combine(_copy, "site", "i18n", "xr.json")); - foreach (var folder in Directory.EnumerateDirectories(Path.Combine(_copy, "site", "pages"))) - { - File.Copy(Path.Combine(folder, "pl.html"), Path.Combine(folder, "xr.html")); - } - - var problems = Build(); - - var rtl = File.ReadAllText(Path.Combine(_output, "xr", "faq", "index.html")); - var english = File.ReadAllText(Path.Combine(_output, "faq", "index.html")); - - Assert.Empty(problems); - Assert.Contains("", rtl, StringComparison.Ordinal); - Assert.Contains("", english, StringComparison.Ordinal); - Assert.Contains("hreflang=\"xr\" href=\"https://betterwindowsservices.donislawdev.com/xr/faq/\"", english, StringComparison.Ordinal); - Assert.Contains(">Test language<", english, StringComparison.Ordinal); - } - - [Fact] - public void A_link_written_as_a_url_token_resolves_and_a_mistyped_one_is_reported() - { - DamagePage("faq", "pl", "href=\"/pl/sc-exe-i-powershell/\"", "href=\"{{url:sc-exe-and-powershell}}\""); - Assert.Empty(Build()); - - // Resolved in the language of the PAGE THAT WRITES IT. The address of the English page - // exists too, so the link check alone would pass a token that resolved to the wrong one. - // The Polish address is also in this page's navigation and footer, so asserting that it - // is present would pass either way - what only a wrong resolution can put on a Polish - // page is the English address. - var polish = File.ReadAllText(Path.Combine(_output, "pl", "faq", "index.html")); - Assert.Contains("href=\"/pl/sc-exe-i-powershell/\"", polish, StringComparison.Ordinal); - Assert.DoesNotContain("href=\"/sc-exe-and-powershell/\"", polish, StringComparison.Ordinal); - - DamagePage("faq", "pl", "{{url:sc-exe-and-powershell}}", "{{url:sc-exe-and-powershel}}"); - Assert.Contains(Build(), problem => problem.Contains("no page has the id 'sc-exe-and-powershel'", StringComparison.Ordinal)); - } - - private IReadOnlyList Build() - { - var build = new Build(_copy, _output); - build.Run(); - return build.Problems; - } - - /// Adds a language to the published list, whatever the list holds now. - private void Publish(string code) => - EditSite(text => System.Text.RegularExpressions.Regex.Replace( - text, - "(\"languages\"\\s*:\\s*\\[[^\\]]*)\\]", - "$1, \"" + code + "\"]", - System.Text.RegularExpressions.RegexOptions.None, - TimeSpan.FromSeconds(1))); - - /// Adds an invented language to the registry, so a test never depends on a real one being published or not. - private void Register(string code, string dir, string slugs) => - EditSite(text => text.Replace( - "\"language_registry\": {", - "\"language_registry\": {\n \"" + code + "\": { \"tag\": \"" + code + "\", \"locale\": \"" + code + "_XX\", \"name\": \"Test language\", \"short\": \"" + code.ToUpperInvariant() + "\", \"dir\": \"" + dir + "\", \"slugs\": \"" + slugs + "\" },", - StringComparison.Ordinal)); - - private void EditSite(Func edit) - { - var path = Path.Combine(_copy, "site", "site.json"); - var before = File.ReadAllText(path); - var after = edit(before); - - // Same rule as the page damage: an edit that changed nothing would let the test pass - // against a generator that checks nothing. - Assert.NotEqual(before, after); - File.WriteAllText(path, after); - } - - private string StampLine(string id, string language) - { - var text = File.ReadAllText(Path.Combine(_copy, "site", "pages", id, language + ".html")); - var match = System.Text.RegularExpressions.Regex.Match( - text, - "", - System.Text.RegularExpressions.RegexOptions.None, - TimeSpan.FromSeconds(1)); - Assert.True(match.Success, $"{id}/{language}.html carries no stamp to remove"); - return match.Value; - } - - /// - /// Breaks one thing in one page fragment. - /// - /// The path is built from segments rather than written as one string, and that is not - /// a style preference. A page folder in this project is named after the page, one of them is - /// the front page, and a slash-separated path through that folder ending in a file carries - /// the shape of a unix home directory inside it - which PublicSurfaceGuards sweeps every - /// published file for. It went red the first run this file reached CI, and red again on the - /// comment that first tried to explain it by quoting the path. The guard is right both - /// times: weakening a privacy sweep to fit a test path would be the wrong half of the trade. - /// - private void DamagePage(string id, string language, string from, string to) - { - var path = Path.Combine(_copy, "site", "pages", id, language + ".html"); - var text = File.ReadAllText(path); - - // The damage has to land, or the test would pass against a generator that checks - // nothing - the assertion would simply never be reached with anything broken. - Assert.Contains(from, text, StringComparison.Ordinal); - File.WriteAllText(path, text.Replace(from, to, StringComparison.Ordinal)); - } - - private static void CopySite(string root, string target) - { - foreach (var relative in new[] { "site", "README.md", "Directory.Build.props", "BetterWindowsServices.slnx" }) - { - var source = Path.Combine(root, relative); - if (File.Exists(source)) - { - Directory.CreateDirectory(target); - File.Copy(source, Path.Combine(target, relative)); - continue; - } - - foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) - { - // bin and obj under site/Bws.Site are the generator's own build output and - // copying them would take seconds and megabytes for nothing. - if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) - || file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) - { - continue; - } - - var destination = Path.Combine(target, Path.GetRelativePath(root, file)); - Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - File.Copy(file, destination); - } - } - - // The exit codes and the switches are read out of the shipped source as text, so the - // copy needs those two files and nothing else from src. - foreach (var relative in new[] { "src/Bws.Cli/ExitCode.cs", "src/Bws.Cli/OptionSurface.cs" }) - { - var destination = Path.Combine(target, relative.Replace('/', Path.DirectorySeparatorChar)); - Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - File.Copy(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)), destination); - } - } -} - -internal static class Repository -{ - internal static string Root() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "BetterWindowsServices.slnx"))) - { - directory = directory.Parent; - } - - return directory?.FullName ?? throw new InvalidOperationException("No repository root above " + AppContext.BaseDirectory); - } } diff --git a/tests/Bws.Site.Tests/LanguageTests.cs b/tests/Bws.Site.Tests/LanguageTests.cs new file mode 100644 index 00000000..59999ae0 --- /dev/null +++ b/tests/Bws.Site.Tests/LanguageTests.cs @@ -0,0 +1,220 @@ +namespace Bws.Site.Tests; + +/// +/// The site's languages, asked whether the generator NOTICES what goes wrong with one. +/// +/// Same rule as : every test copies the real site, damages one thing +/// and asserts the build says so. These are the ones a language can break - its registry entry, its +/// slugs, its shape against the English page, the stamp that says which English it follows, and what +/// the generator writes for a right-to-left language. None of them names a language that is really +/// published, so adding one never edits this file. +/// +public sealed class LanguageTests : SiteTestBase +{ + [Fact] + public void A_translation_with_a_different_shape_from_the_original_is_reported() + { + // A class is the cheapest damage that is still a difference in structure: the page + // looks the same to a reader and is not the page the original is. + DamagePage("faq", "pl", "
    ", "
    "); + + Assert.Contains(Build(), problem => problem.Contains("parity: faq/pl.html does not have the shape", StringComparison.Ordinal)); + } + + [Fact] + public void A_translation_that_changes_a_command_is_reported() + { + DamagePage("faq", "pl", "start:auto !status:running", "start:auto !status:runing"); + + Assert.Contains(Build(), problem => problem.Contains("parity: faq/pl.html has different code", StringComparison.Ordinal)); + } + + [Fact] + public void A_translation_that_loses_a_token_is_reported() + { + DamagePage("home", "pl", "{{releases}}", "https://github.com"); + + Assert.Contains(Build(), problem => problem.Contains("parity: home/pl.html uses different tokens", StringComparison.Ordinal)); + } + + [Fact] + public void A_translation_without_a_stamp_is_a_problem() + { + DamagePage("home", "pl", StampLine("home", "pl"), ""); + + Assert.Contains(Build(), problem => problem.Contains("freshness: home/pl.html has no translated-from stamp", StringComparison.Ordinal)); + } + + /// + /// The owner's decision of 2026-10-21 in one test: English moves on, the translation is + /// LISTED and the build is still clean - and the same list is a failure when asked for. + /// + [Fact] + public void A_translation_older_than_the_english_is_listed_and_fails_only_when_asked() + { + var english = Path.Combine(Copy, "site", "pages", "home", "en.html"); + File.AppendAllText(english, ""); + + var plain = new Build(Copy, Output); + plain.Run(); + Assert.Empty(plain.Problems); + Assert.Contains(plain.Stale, line => line.Contains("home/pl.html", StringComparison.Ordinal)); + + var strict = new Build(Copy, Output, requireFresh: true); + strict.Run(); + Assert.Contains(strict.Problems, problem => problem.Contains("freshness: home/pl.html", StringComparison.Ordinal)); + } + + [Fact] + public void Stamping_a_language_makes_it_fresh_and_a_second_stamp_changes_nothing() + { + File.AppendAllText(Path.Combine(Copy, "site", "pages", "home", "en.html"), ""); + + var site = SiteConfig.Load(Copy); + var problems = new Problems(); + var pages = Pages.LoadDefinitions(Copy, site, problems); + + var first = Freshness.Stamp(Copy, site, "all", pages, out _); + var second = Freshness.Stamp(Copy, site, "all", pages, out _); + + var build = new Build(Copy, Output, requireFresh: true); + build.Run(); + + // One file per translation: only the English front page moved, so only its translations + // were stamped again. The other eleven pages and the language files already carried the + // right stamp. The number of translations is counted, not written, so adding a language + // does not break this. + var translations = Directory.EnumerateFiles(Path.Combine(Copy, "site", "pages", "home"), "*.html").Count() - 1; + + Assert.Empty(problems.Found); + Assert.Equal(translations, first); + Assert.Equal(0, second); + Assert.Empty(build.Problems); + } + + /// + /// The working tree on the owner's machine is CRLF and the repository is LF. A hash that saw + /// the difference would call every translation stale on one of the two, and the list would + /// be wrong in a way nobody could act on. + /// + [Fact] + public void A_difference_in_line_endings_alone_does_not_make_a_translation_stale() + { + var english = Path.Combine(Copy, "site", "pages", "home", "en.html"); + var text = File.ReadAllText(english).Replace("\r\n", "\n", StringComparison.Ordinal); + File.WriteAllText(english, text.Replace("\n", "\r\n", StringComparison.Ordinal)); + + var build = new Build(Copy, Output); + build.Run(); + + Assert.Empty(build.Problems); + Assert.Empty(build.Stale); + } + + /// + /// French writes a no-break space before a colon and a question mark, as an entity. The + /// structured data is JSON read by a machine, so the entity has to be turned into the + /// character there - what it says to a search engine otherwise is the letters. + /// + [Fact] + public void A_no_break_space_in_a_question_reaches_the_structured_data_as_the_character() + { + DamagePage("faq", "en", "Does it need the internet?", "Does it need the internet ?"); + + Assert.Empty(Build()); + + var page = File.ReadAllText(Path.Combine(Output, "faq", "index.html")); + var start = page.IndexOf("application/ld+json", StringComparison.Ordinal); + Assert.True(start >= 0, "the questions page should carry structured data"); + + var data = page[start..page.IndexOf("", start, StringComparison.Ordinal)]; + Assert.Contains("the internet\u00A0?", data, StringComparison.Ordinal); + Assert.DoesNotContain(" ", data, StringComparison.Ordinal); + } + + [Fact] + public void A_file_for_a_language_the_registry_does_not_list_is_reported() + { + File.Copy( + Path.Combine(Copy, "site", "pages", "faq", "pl.html"), + Path.Combine(Copy, "site", "pages", "faq", "xx.html")); + + Assert.Contains(Build(), problem => problem.Contains("faq/xx.html belongs to no language", StringComparison.Ordinal)); + } + + [Fact] + public void A_published_language_with_no_registry_entry_stops_the_build_with_a_reason() + { + Publish("xx"); + + Assert.Contains(Build(), problem => problem.Contains("'xx' is published under languages and has no entry", StringComparison.Ordinal)); + } + + [Fact] + public void A_direction_that_is_neither_ltr_nor_rtl_is_reported() + { + EditSite(text => text.Replace("\"dir\": \"rtl\"", "\"dir\": \"sideways\"", StringComparison.Ordinal)); + + Assert.Contains(Build(), problem => problem.Contains("sideways", StringComparison.Ordinal)); + } + + [Fact] + public void A_slug_for_a_language_whose_slugs_are_english_is_reported() + { + Register("xe", "ltr", "english"); + Publish("xe"); + var path = Path.Combine(Copy, "site", "pages", "faq", "page.json"); + File.WriteAllText(path, File.ReadAllText(path).Replace("\"pl\": \"faq\"", "\"pl\": \"faq\", \"xe\": \"faq\"", StringComparison.Ordinal)); + + Assert.Contains(Build(), problem => problem.Contains("has a slug for 'xe', whose registry entry says slugs are english", StringComparison.Ordinal)); + } + + /// + /// What a right-to-left language gets, through the real generator. The language is invented + /// for the test and made out of the Polish files - the same shape, the same stamps, an + /// invented registry entry - which is exactly enough for the generator to have nothing to + /// complain about and a page to be written for it. It is invented, not Arabic, so the test + /// does not change the day Arabic is published. + /// + [Fact] + public void A_right_to_left_language_is_written_with_its_direction_and_listed_in_the_switch() + { + Register("xr", "rtl", "english"); + Publish("xr"); + File.Copy(Path.Combine(Copy, "site", "i18n", "pl.json"), Path.Combine(Copy, "site", "i18n", "xr.json")); + foreach (var folder in Directory.EnumerateDirectories(Path.Combine(Copy, "site", "pages"))) + { + File.Copy(Path.Combine(folder, "pl.html"), Path.Combine(folder, "xr.html")); + } + + var problems = Build(); + + var rtl = File.ReadAllText(Path.Combine(Output, "xr", "faq", "index.html")); + var english = File.ReadAllText(Path.Combine(Output, "faq", "index.html")); + + Assert.Empty(problems); + Assert.Contains("", rtl, StringComparison.Ordinal); + Assert.Contains("", english, StringComparison.Ordinal); + Assert.Contains("hreflang=\"xr\" href=\"https://betterwindowsservices.donislawdev.com/xr/faq/\"", english, StringComparison.Ordinal); + Assert.Contains(">Test language<", english, StringComparison.Ordinal); + } + + [Fact] + public void A_link_written_as_a_url_token_resolves_and_a_mistyped_one_is_reported() + { + DamagePage("faq", "pl", "href=\"/pl/sc-exe-i-powershell/\"", "href=\"{{url:sc-exe-and-powershell}}\""); + Assert.Empty(Build()); + + // Resolved in the language of the PAGE THAT WRITES IT. The address of the English page + // exists too, so the link check alone would pass a token that resolved to the wrong one. + // The Polish address is also in this page's navigation and footer, so asserting that it + // is present would pass either way - what only a wrong resolution can put on a Polish + // page is the English address. + var polish = File.ReadAllText(Path.Combine(Output, "pl", "faq", "index.html")); + Assert.Contains("href=\"/pl/sc-exe-i-powershell/\"", polish, StringComparison.Ordinal); + Assert.DoesNotContain("href=\"/sc-exe-and-powershell/\"", polish, StringComparison.Ordinal); + + DamagePage("faq", "pl", "{{url:sc-exe-and-powershell}}", "{{url:sc-exe-and-powershel}}"); + Assert.Contains(Build(), problem => problem.Contains("no page has the id 'sc-exe-and-powershel'", StringComparison.Ordinal)); + } +} diff --git a/tests/Bws.Site.Tests/SiteTestBase.cs b/tests/Bws.Site.Tests/SiteTestBase.cs new file mode 100644 index 00000000..be373eff --- /dev/null +++ b/tests/Bws.Site.Tests/SiteTestBase.cs @@ -0,0 +1,155 @@ +namespace Bws.Site.Tests; + +/// +/// The sandbox every generator test works in: a copy of the real site in a temporary directory, +/// which a test damages in one place before asking the build whether it noticed. +/// +/// Split out of GeneratorTests on 2026-10-21 when the languages' tests took that file over +/// the ceiling the size ratchet holds test files to. The ceiling says split it, and the seam is +/// the one the file already had: what a test needs in order to break something, and the tests that +/// do the breaking. +/// +public abstract class SiteTestBase : IDisposable +{ + protected SiteTestBase() + { + Copy = Path.Combine(Path.GetTempPath(), "bws-site-tests", Guid.NewGuid().ToString("n")); + Output = Path.Combine(Copy, "out"); + CopySite(Repository.Root(), Copy); + } + + /// The temporary copy of the repository's site this test works on. + protected string Copy { get; } + + /// Where the build writes. + protected string Output { get; } + + public void Dispose() + { + if (Directory.Exists(Copy)) + { + Directory.Delete(Copy, recursive: true); + } + } + + protected IReadOnlyList Build() + { + var build = new Build(Copy, Output); + build.Run(); + return build.Problems; + } + + /// Adds a language to the published list, whatever the list holds now. + protected void Publish(string code) => + EditSite(text => System.Text.RegularExpressions.Regex.Replace( + text, + "(\"languages\"\\s*:\\s*\\[[^\\]]*)\\]", + "$1, \"" + code + "\"]", + System.Text.RegularExpressions.RegexOptions.None, + TimeSpan.FromSeconds(1))); + + /// Adds an invented language to the registry, so a test never depends on a real one being published or not. + protected void Register(string code, string dir, string slugs) => + EditSite(text => text.Replace( + "\"language_registry\": {", + "\"language_registry\": {\n \"" + code + "\": { \"tag\": \"" + code + "\", \"locale\": \"" + code + "_XX\", \"name\": \"Test language\", \"short\": \"" + code.ToUpperInvariant() + "\", \"dir\": \"" + dir + "\", \"slugs\": \"" + slugs + "\" },", + StringComparison.Ordinal)); + + protected void EditSite(Func edit) + { + var path = Path.Combine(Copy, "site", "site.json"); + var before = File.ReadAllText(path); + var after = edit(before); + + // Same rule as the page damage: an edit that changed nothing would let the test pass + // against a generator that checks nothing. + Assert.NotEqual(before, after); + File.WriteAllText(path, after); + } + + protected string StampLine(string id, string language) + { + var text = File.ReadAllText(Path.Combine(Copy, "site", "pages", id, language + ".html")); + var match = System.Text.RegularExpressions.Regex.Match( + text, + "", + System.Text.RegularExpressions.RegexOptions.None, + TimeSpan.FromSeconds(1)); + Assert.True(match.Success, $"{id}/{language}.html carries no stamp to remove"); + return match.Value; + } + + /// + /// Breaks one thing in one page fragment. + /// + /// The path is built from segments rather than written as one string, and that is not + /// a style preference. A page folder in this project is named after the page, one of them is + /// the front page, and a slash-separated path through that folder ending in a file carries + /// the shape of a unix home directory inside it - which PublicSurfaceGuards sweeps every + /// published file for. It went red the first run this file reached CI, and red again on the + /// comment that first tried to explain it by quoting the path. The guard is right both + /// times: weakening a privacy sweep to fit a test path would be the wrong half of the trade. + /// + protected void DamagePage(string id, string language, string from, string to) + { + var path = Path.Combine(Copy, "site", "pages", id, language + ".html"); + var text = File.ReadAllText(path); + + // The damage has to land, or the test would pass against a generator that checks + // nothing - the assertion would simply never be reached with anything broken. + Assert.Contains(from, text, StringComparison.Ordinal); + File.WriteAllText(path, text.Replace(from, to, StringComparison.Ordinal)); + } + + private static void CopySite(string root, string target) + { + foreach (var relative in new[] { "site", "README.md", "Directory.Build.props", "BetterWindowsServices.slnx" }) + { + var source = Path.Combine(root, relative); + if (File.Exists(source)) + { + Directory.CreateDirectory(target); + File.Copy(source, Path.Combine(target, relative)); + continue; + } + + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + // bin and obj under site/Bws.Site are the generator's own build output and + // copying them would take seconds and megabytes for nothing. + if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + { + continue; + } + + var destination = Path.Combine(target, Path.GetRelativePath(root, file)); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(file, destination); + } + } + + // The exit codes and the switches are read out of the shipped source as text, so the + // copy needs those two files and nothing else from src. + foreach (var relative in new[] { "src/Bws.Cli/ExitCode.cs", "src/Bws.Cli/OptionSurface.cs" }) + { + var destination = Path.Combine(target, relative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)), destination); + } + } +} + +internal static class Repository +{ + internal static string Root() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "BetterWindowsServices.slnx"))) + { + directory = directory.Parent; + } + + return directory?.FullName ?? throw new InvalidOperationException("No repository root above " + AppContext.BaseDirectory); + } +} diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index 6e040ab3..cb466c56 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -6817,7 +6817,7 @@ $mutations = @( File = 'site\Bws.Site\Parity.cs' From = ' if (firstDifference >= 0)' To = ' if (firstDifference < -1)' - Test = 'GeneratorTests.A_translation_with_a_different_shape_from_the_original_is_reported' + Test = 'LanguageTests.A_translation_with_a_different_shape_from_the_original_is_reported' } @{ # A command the reader pastes into a shell is the one thing a translation must not change. @@ -6825,7 +6825,7 @@ $mutations = @( File = 'site\Bws.Site\Parity.cs' From = ' if (missing.Count > 0 || extra.Count > 0)' To = ' if (missing.Count > 99 || extra.Count > 99)' - Test = 'GeneratorTests.A_translation_that_changes_a_command_is_reported' + Test = 'LanguageTests.A_translation_that_changes_a_command_is_reported' } @{ # A page that loses a token loses the link it stood for. The shape check also fails on that @@ -6834,7 +6834,7 @@ $mutations = @( File = 'site\Bws.Site\Parity.cs' From = ' if (tokenMissing.Count > 0 || tokenExtra.Count > 0)' To = ' if (tokenMissing.Count > 99 || tokenExtra.Count > 99)' - Test = 'GeneratorTests.A_translation_that_loses_a_token_is_reported' + Test = 'LanguageTests.A_translation_that_loses_a_token_is_reported' } @{ # The owner decided on 2026-10-21 that an old translation is LISTED, so the comparison is @@ -6843,7 +6843,7 @@ $mutations = @( File = 'site\Bws.Site\Freshness.cs' From = ' else if (!string.Equals(stamp, current, StringComparison.Ordinal))' To = ' else if (string.Equals(stamp, current, StringComparison.Ordinal))' - Test = 'GeneratorTests.A_translation_older_than_the_english_is_listed_and_fails_only_when_asked' + Test = 'LanguageTests.A_translation_older_than_the_english_is_listed_and_fails_only_when_asked' } @{ # A translation that cannot say what it follows can never be called old, so a missing stamp @@ -6852,7 +6852,7 @@ $mutations = @( File = 'site\Bws.Site\Freshness.cs' From = ' if (stamp is null)' To = ' if (stamp is not null)' - Test = 'GeneratorTests.A_translation_without_a_stamp_is_a_problem' + Test = 'LanguageTests.A_translation_without_a_stamp_is_a_problem' } @{ # The working tree is CRLF and the repository LF. A hash that saw the difference would call @@ -6861,7 +6861,7 @@ $mutations = @( File = 'site\Bws.Site\Freshness.cs' From = ' var normal = text.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();' To = ' var normal = text.Trim();' - Test = 'GeneratorTests.A_difference_in_line_endings_alone_does_not_make_a_translation_stale' + Test = 'LanguageTests.A_difference_in_line_endings_alone_does_not_make_a_translation_stale' } @{ # A file named after a code that is nowhere is a typo that would sit built by nothing. The @@ -6870,7 +6870,7 @@ $mutations = @( File = 'site\Bws.Site\Freshness.cs' From = ' if (!site.LanguageRegistry.ContainsKey(code))' To = ' if (site.LanguageRegistry.ContainsKey(code))' - Test = 'GeneratorTests.A_file_for_a_language_the_registry_does_not_list_is_reported' + Test = 'LanguageTests.A_file_for_a_language_the_registry_does_not_list_is_reported' } @{ # Every page asks the registry for its language, so a published language with no entry has to @@ -6879,14 +6879,14 @@ $mutations = @( File = 'site\Bws.Site\SiteConfig.cs' From = ' foreach (var code in Languages.Where(code => !LanguageRegistry.ContainsKey(code)))' To = ' foreach (var code in Languages.Where(code => LanguageRegistry.ContainsKey(code)))' - Test = 'GeneratorTests.A_published_language_with_no_registry_entry_stops_the_build_with_a_reason' + Test = 'LanguageTests.A_published_language_with_no_registry_entry_stops_the_build_with_a_reason' } @{ Name = 'site: a direction that is neither ltr nor rtl is reported' File = 'site\Bws.Site\SiteConfig.cs' From = ' if (info.Dir is not ("ltr" or "rtl"))' To = ' if (info.Dir is not ("ltr" or "rtl" or "sideways"))' - Test = 'GeneratorTests.A_direction_that_is_neither_ltr_nor_rtl_is_reported' + Test = 'LanguageTests.A_direction_that_is_neither_ltr_nor_rtl_is_reported' } @{ # A language the registry sets to english slugs would carry a slug nothing reads. Inverted, @@ -6895,7 +6895,7 @@ $mutations = @( File = 'site\Bws.Site\Pages.cs' From = ' if (info.Slugs != "own")' To = ' if (info.Slugs == "own")' - Test = 'GeneratorTests.A_slug_for_a_language_whose_slugs_are_english_is_reported' + Test = 'LanguageTests.A_slug_for_a_language_whose_slugs_are_english_is_reported' } @{ # dir is written only for a right-to-left language. Inverted, English carries dir=ltr and @@ -6904,7 +6904,7 @@ $mutations = @( File = 'site\Bws.Site\Renderer.cs' From = ' var direction = info.Dir == "ltr" ? "" : $" dir=\"{info.Dir}\"";' To = ' var direction = info.Dir != "ltr" ? "" : $" dir=\"{info.Dir}\"";' - Test = 'GeneratorTests.A_right_to_left_language_is_written_with_its_direction_and_listed_in_the_switch' + Test = 'LanguageTests.A_right_to_left_language_is_written_with_its_direction_and_listed_in_the_switch' } @{ # {{url:id}} is the address in the language of the page that writes it. The English page @@ -6913,7 +6913,7 @@ $mutations = @( File = 'site\Bws.Site\Tokens.cs' From = ' return target.Url(language, _site);' To = ' return target.Url(_site.DefaultLanguage, _site);' - Test = 'GeneratorTests.A_link_written_as_a_url_token_resolves_and_a_mistyped_one_is_reported' + Test = 'LanguageTests.A_link_written_as_a_url_token_resolves_and_a_mistyped_one_is_reported' } @{ # French writes a no-break space before a colon and a question mark, as an entity, and the @@ -6922,7 +6922,7 @@ $mutations = @( File = 'site\Bws.Site\StructuredData.cs' From = ' .Replace(" ", "\u00A0", StringComparison.Ordinal)' To = ' .Replace(" ", " ", StringComparison.Ordinal)' - Test = 'GeneratorTests.A_no_break_space_in_a_question_reaches_the_structured_data_as_the_character' + Test = 'LanguageTests.A_no_break_space_in_a_question_reaches_the_structured_data_as_the_character' } @{