From 2fd3beb9aef60ce591c0ef3819aa9b62adb49e09 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 28 Aug 2026 12:06:08 +0700 Subject: [PATCH 1/2] chore(find-language): add BCP-47 validation cases ported from libpalaso Broaden isValidBcp47Tag coverage with well-formedness cases drawn from libpalaso's IetfLanguageTag tests: full subtag-position tags, extlang limits, Unicode/transform extensions, private-use length limits, and empty-subtag/illegal-character rejections. Also add a doc comment clarifying that isValidBcp47Tag checks syntactic validity only, not registry membership (unlike libpalaso's IsValid). Co-Authored-By: Claude Opus 4.8 --- .../find-language/languageTagUtils.spec.ts | 46 +++++++++++++++++++ .../common/find-language/languageTagUtils.ts | 2 + 2 files changed, 48 insertions(+) diff --git a/components/language-chooser/common/find-language/languageTagUtils.spec.ts b/components/language-chooser/common/find-language/languageTagUtils.spec.ts index 0256bbde..3d3791e8 100644 --- a/components/language-chooser/common/find-language/languageTagUtils.spec.ts +++ b/components/language-chooser/common/find-language/languageTagUtils.spec.ts @@ -797,6 +797,52 @@ describe("isValidBcp47Tag checking is sane", () => { isValidBcp47Tag("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") ).toBeFalsy(); }); + + // The following cases are ported from libpalaso's IetfLanguageTag tests + // (SIL.WritingSystems.Tests/IetfLanguageTagTests.cs) to check that our + // *syntactic* validator agrees with libpalaso on tag well-formedness. + // Note: libpalaso's IetfLanguageTag.IsValid additionally validates subtags + // against the IANA/ISO registries (so e.g. "zzz" is invalid there), which is + // deliberately out of scope for this syntactic check - see the "unrecognized + // tags in the right format" case above. + it("should return true for well-formed tags with each subtag position filled", () => { + expect(isValidBcp47Tag("tpi-AR")).toBeTruthy(); // language-region + expect(isValidBcp47Tag("qed-Lepc-x-rubbish")).toBeTruthy(); // language-script-privateuse + expect(isValidBcp47Tag("qed")).toBeTruthy(); // private-use range language + // language-script-region-variant-privateuse + expect(isValidBcp47Tag("tpi-Lepc-BR-fonipa-x-blah")).toBeTruthy(); + }); + + it("should return true for extlang subtags (up to the 3-extlang maximum)", () => { + expect(isValidBcp47Tag("en-abc")).toBeTruthy(); + expect(isValidBcp47Tag("en-abc-def-ghi")).toBeTruthy(); + }); + + it("should return false for more than three extlang subtags", () => { + expect(isValidBcp47Tag("abcdefgh-abc-def-ghi-jkl")).toBeFalsy(); + expect(isValidBcp47Tag("en-abc-def-ghi-jkl")).toBeFalsy(); + }); + + it("should return true for tags with Unicode and transform extensions", () => { + expect(isValidBcp47Tag("en-US-u-ca-gregory")).toBeTruthy(); + expect(isValidBcp47Tag("de-DE-u-co-phonebk")).toBeTruthy(); + expect(isValidBcp47Tag("en-t-jp")).toBeTruthy(); + }); + + it("should return true/false for private-use subtags per the 8-char length limit", () => { + expect(isValidBcp47Tag("x-abcdefgh")).toBeTruthy(); // 8 chars, at the limit + expect(isValidBcp47Tag("x-abcdefghijklmnop")).toBeFalsy(); // 16 chars, too long + }); + + it("should return false for empty subtags and illegal characters", () => { + expect(isValidBcp47Tag("a")).toBeFalsy(); // single character + expect(isValidBcp47Tag("-")).toBeFalsy(); + expect(isValidBcp47Tag("--")).toBeFalsy(); + expect(isValidBcp47Tag("---")).toBeFalsy(); + expect(isValidBcp47Tag("en--US")).toBeFalsy(); // empty subtag in the middle + expect(isValidBcp47Tag("en-Latn-US-x-")).toBeFalsy(); // trailing empty private use + expect(isValidBcp47Tag("qed-?~")).toBeFalsy(); // illegal characters + }); }); describe("sanity checks for isUnlistedLanguage and isManuallyEnteredTagLanguage", () => { diff --git a/components/language-chooser/common/find-language/languageTagUtils.ts b/components/language-chooser/common/find-language/languageTagUtils.ts index 5e9c0a1b..9026f7bc 100644 --- a/components/language-chooser/common/find-language/languageTagUtils.ts +++ b/components/language-chooser/common/find-language/languageTagUtils.ts @@ -223,6 +223,8 @@ export function isManuallyEnteredTagLanguage( ); } +// Checks *syntactic* validity of a BCP-47 tag (well-formed subtags), not whether the +// language/region/script subtags are registered in the IANA Language Subtag Registry. export function isValidBcp47Tag(tag: string | undefined): boolean { // from https://stackoverflow.com/questions/7035825/regular-expression-for-a-language-tag-as-defined-by-bcp47 const bcp47Regex = From 60228b9209a44d6404ba2158d5f1c822ac738c96 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 2 Sep 2026 09:29:47 +0700 Subject: [PATCH 2/2] chore(find-language): tighten extlang-limit test per review Drop the abcdefgh-abc-def-ghi-jkl case: an 8-letter primary language subtag cannot carry any extlang in our grammar, so it was rejected for that reason rather than for exceeding three extlangs and tested nothing. en-abc-def-ghi-jkl (2-letter primary + four extlangs) exercises the limit directly. Co-Authored-By: Claude Opus 4.8 --- .../common/find-language/languageTagUtils.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/language-chooser/common/find-language/languageTagUtils.spec.ts b/components/language-chooser/common/find-language/languageTagUtils.spec.ts index 3d3791e8..f11286ee 100644 --- a/components/language-chooser/common/find-language/languageTagUtils.spec.ts +++ b/components/language-chooser/common/find-language/languageTagUtils.spec.ts @@ -819,7 +819,8 @@ describe("isValidBcp47Tag checking is sane", () => { }); it("should return false for more than three extlang subtags", () => { - expect(isValidBcp47Tag("abcdefgh-abc-def-ghi-jkl")).toBeFalsy(); + // en- (2-letter primary) can carry extlangs, so this exercises the 3-extlang + // limit specifically: four extlangs is one too many. expect(isValidBcp47Tag("en-abc-def-ghi-jkl")).toBeFalsy(); });