From ccb8b90ab9800a26e6518fdca78afb4821ccc393 Mon Sep 17 00:00:00 2001 From: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:54:40 -0800 Subject: [PATCH 1/3] fix(gottext): name a translation whose tags differ from its message --- gottext/CHANGELOG.md | 9 ++++ gottext/src/gates.ts | 34 +++++++++++-- gottext/test/build.test.ts | 99 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 3 deletions(-) diff --git a/gottext/CHANGELOG.md b/gottext/CHANGELOG.md index 1ab8046..00a45f9 100644 --- a/gottext/CHANGELOG.md +++ b/gottext/CHANGELOG.md @@ -5,6 +5,15 @@ versioning follows [Semantic Versioning](https://semver.org/). While at 0.x, minor releases may break. Releases are tagged `gottext/vX.Y.Z` and publish from CI. +## [Unreleased] + +### Fixed + +- `mismatched` reads the tags of each translated form with the pattern + `createInterpolateElement` uses and compares them with the tags of its + message, so a translation that adds, drops, renames or repeats a tag is + named, while one that only moves a tag still passes. + ## [0.8.0] - 2026-11-02 ### Changed diff --git a/gottext/src/gates.ts b/gottext/src/gates.ts index 0677c85..96b8560 100644 --- a/gottext/src/gates.ts +++ b/gottext/src/gates.ts @@ -122,6 +122,32 @@ function answersPlaceholders(form: string, message: string): boolean { return alike(placeholders(form), placeholders(message)) && alike(bare(form), bare(message)) } +/** TAG is a tag as createInterpolateElement reads one, with the slashes that mark it closing or self closing. */ +const TAG = /<(\/)?(\w+)\s*(\/)?>/g + +/** + * Returns the tags a message carries, each whole and once per use, in a settled order. + * An opening, a closing and a self closing tag read as different tags, and spaces inside a tag do not count. + * @param message - The message to read. + * @returns The tags, sorted. + */ +function tags(message: string): string[] { + return [...message.matchAll(TAG)].map((found) => `${found[1] ?? ''}${found[2]}${found[3] ?? ''}`).sort() +} + +/** + * Returns whether a translated form answers its message with the tags that message carries. + * @param form - The translated form. + * @param message - The message the form answers. + * @returns Whether the form matches. + */ +function answersTags(form: string, message: string): boolean { + if (form === '') { + return true + } + return alike(tags(form), tags(message)) +} + /** NUMBER is the conversion a placeholder ends in when it writes a number rather than text. */ const NUMBER = /[deEfgGiu]$/ @@ -152,7 +178,7 @@ export function unformatted(template: string): string[] { } /** - * Returns every message whose translation names placeholders its message does not. + * Returns every message whose translation names placeholders or tags its message does not. * @param source - The catalogue as PO text. * @param template - The template naming every message and the placeholders it carries. * @returns The keys whose translation would not render. @@ -167,8 +193,10 @@ export function mismatched(source: string, template: string): string[] { continue } const plural = entry.msgid_plural ?? msgid - const matches = answer.msgstr.every((form, at) => - answersPlaceholders(form, at === 0 ? msgid : plural)) + const matches = answer.msgstr.every((form, at) => { + const message = at === 0 ? msgid : plural + return answersPlaceholders(form, message) && answersTags(form, message) + }) if (!matches) { broken.push(keyOf(context, msgid)) } diff --git a/gottext/test/build.test.ts b/gottext/test/build.test.ts index 2c4551f..a1e4624 100644 --- a/gottext/test/build.test.ts +++ b/gottext/test/build.test.ts @@ -597,6 +597,105 @@ msgstr[1] "%(total)d entradas" expect(mismatched(broken, naming)).toEqual(['%(count)d post']) }) +test('names a translation adding a tag its message does not carry', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + const added = 'msgid "x y"\nmsgstr "x y"\n' + + expect(mismatched(added, naming)).toEqual(['x y']) +}) + +test('names a translation dropping a tag its message carries', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + const dropped = 'msgid "x y"\nmsgstr "x y"\n' + + expect(mismatched(dropped, naming)).toEqual(['x y']) +}) + +test('names a translation renaming a tag its message carries', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + const renamed = 'msgid "x y"\nmsgstr "x y"\n' + + expect(mismatched(renamed, naming)).toEqual(['x y']) +}) + +test('names a translation repeating a tag its message carries', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + const repeated = 'msgid "x y"\nmsgstr "x y"\n' + + expect(mismatched(repeated, naming)).toEqual(['x y']) +}) + +test('names a translation answering an opening and a closing tag with a self closing one', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + const selfClosed = 'msgid "x y"\nmsgstr "x y"\n' + + expect(mismatched(selfClosed, naming)).toEqual(['x y']) +}) + +test('names a translation opening a tag its message carries self closing', () => { + const naming = 'msgid "a
b"\nmsgstr ""\n' + const opened = 'msgid "a
b"\nmsgstr "a
b"\n' + + expect(mismatched(opened, naming)).toEqual(['a
b']) +}) + +test('names a translation whose plural form drops a tag', () => { + const naming = `msgid "one post" +msgid_plural "many posts" +msgstr[0] "" +msgstr[1] "" +` + const broken = `msgid "one post" +msgid_plural "many posts" +msgstr[0] "uno entrada" +msgstr[1] "muchas entradas" +` + + expect(mismatched(broken, naming)).toEqual(['one post']) +}) + +test('names a translation whose singular form repeats a tag', () => { + const naming = `msgid "one post" +msgid_plural "many posts" +msgstr[0] "" +msgstr[1] "" +` + const broken = `msgid "one post" +msgid_plural "many posts" +msgstr[0] "uno uno" +msgstr[1] "muchas entradas" +` + + expect(mismatched(broken, naming)).toEqual(['one post']) +}) + +test('passes a translation moving a tag its message carries', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + const moved = 'msgid "x y"\nmsgstr "y x"\n' + + expect(mismatched(moved, naming)).toEqual([]) +}) + +test('passes a translation carrying spaces inside a tag', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + const spaced = 'msgid "x y"\nmsgstr "x y"\n' + + expect(mismatched(spaced, naming)).toEqual([]) +}) + +test('passes an empty form answering a message carrying tags', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + + expect(mismatched(naming, naming)).toEqual([]) +}) + +test('passes a translation closing a tag before opening it', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + const closedFirst = 'msgid "x y"\nmsgstr "x y"\n' + + expect(mismatched(closedFirst, naming)).toEqual([]) +}) + test('passes over the metadata entry rather than reading it as a message', () => { const naming = 'msgid ""\nmsgstr ""\n\nmsgid "Older posts"\nmsgstr ""\n' From 77bbd27b9084e2b921b81d2295f89b4dfb2dfee0 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 8 Nov 2026 01:00:27 +0100 Subject: [PATCH 2/3] test(gottext): pin closing tags, capital names, tag order and each plural form --- gottext/test/build.test.ts | 42 +++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/gottext/test/build.test.ts b/gottext/test/build.test.ts index a1e4624..3abe3a6 100644 --- a/gottext/test/build.test.ts +++ b/gottext/test/build.test.ts @@ -639,6 +639,27 @@ test('names a translation opening a tag its message carries self closing', () => expect(mismatched(opened, naming)).toEqual(['a
b']) }) +test('names a translation answering a closing tag with an opening one', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + const reopened = 'msgid "x y"\nmsgstr "x y"\n' + + expect(mismatched(reopened, naming)).toEqual(['x y']) +}) + +test('names a translation closing a tag its message carries self closing', () => { + const naming = 'msgid "a
b"\nmsgstr ""\n' + const closed = 'msgid "a
b"\nmsgstr "a
b"\n' + + expect(mismatched(closed, naming)).toEqual(['a
b']) +}) + +test('names a translation dropping a tag whose name carries capitals and digits', () => { + const naming = 'msgid "x y"\nmsgstr ""\n' + const dropped = 'msgid "x y"\nmsgstr "x y"\n' + + expect(mismatched(dropped, naming)).toEqual(['x y']) +}) + test('names a translation whose plural form drops a tag', () => { const naming = `msgid "one post" msgid_plural "many posts" @@ -669,16 +690,31 @@ msgstr[1] "muchas entradas" expect(mismatched(broken, naming)).toEqual(['one post']) }) +test('reads the tags of each plural form against the form it answers', () => { + const naming = `msgid "one post" +msgid_plural "many posts" +msgstr[0] "" +msgstr[1] "" +` + const answered = `msgid "one post" +msgid_plural "many posts" +msgstr[0] "una entrada" +msgstr[1] "muchas entradas" +` + + expect(mismatched(answered, naming)).toEqual([]) +}) + test('passes a translation moving a tag its message carries', () => { - const naming = 'msgid "x y"\nmsgstr ""\n' - const moved = 'msgid "x y"\nmsgstr "y x"\n' + const naming = 'msgid "x y"\nmsgstr ""\n' + const moved = 'msgid "x y"\nmsgstr "y x"\n' expect(mismatched(moved, naming)).toEqual([]) }) test('passes a translation carrying spaces inside a tag', () => { const naming = 'msgid "x y"\nmsgstr ""\n' - const spaced = 'msgid "x y"\nmsgstr "x y"\n' + const spaced = 'msgid "x y"\nmsgstr "x y"\n' expect(mismatched(spaced, naming)).toEqual([]) }) From ac426e252dd7a5fa7c8b122c04cb67318db7e684 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 8 Nov 2026 01:00:27 +0100 Subject: [PATCH 3/3] docs(gottext): trim the tag docblocks and the changelog entry --- gottext/CHANGELOG.md | 5 +---- gottext/src/gates.ts | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/gottext/CHANGELOG.md b/gottext/CHANGELOG.md index 00a45f9..66b7478 100644 --- a/gottext/CHANGELOG.md +++ b/gottext/CHANGELOG.md @@ -9,10 +9,7 @@ from CI. ### Fixed -- `mismatched` reads the tags of each translated form with the pattern - `createInterpolateElement` uses and compares them with the tags of its - message, so a translation that adds, drops, renames or repeats a tag is - named, while one that only moves a tag still passes. +- `mismatched` reads the tags a message carries, so a translation that adds, drops, renames or repeats one is named. ## [0.8.0] - 2026-11-02 diff --git a/gottext/src/gates.ts b/gottext/src/gates.ts index 96b8560..e442f3e 100644 --- a/gottext/src/gates.ts +++ b/gottext/src/gates.ts @@ -100,7 +100,7 @@ function bare(message: string): string[] { } /** - * Returns whether two placeholder lists hold the same members. + * Returns whether two lists hold the same members. * @param left - The list on the left. * @param right - The list on the right. * @returns Whether both hold the same members. @@ -127,7 +127,6 @@ const TAG = /<(\/)?(\w+)\s*(\/)?>/g /** * Returns the tags a message carries, each whole and once per use, in a settled order. - * An opening, a closing and a self closing tag read as different tags, and spaces inside a tag do not count. * @param message - The message to read. * @returns The tags, sorted. */