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 "xy"\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 "xy"\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] "unouno"
+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.
*/