diff --git a/gottext/CHANGELOG.md b/gottext/CHANGELOG.md
index 1ab8046..66b7478 100644
--- a/gottext/CHANGELOG.md
+++ b/gottext/CHANGELOG.md
@@ -5,6 +5,12 @@ 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 a message carries, so a translation that adds, drops, renames or repeats one is named.
+
## [0.8.0] - 2026-11-02
### Changed
diff --git a/gottext/src/gates.ts b/gottext/src/gates.ts
index 0677c85..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.
@@ -122,6 +122,31 @@ 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.
+ * @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 +177,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 +192,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..3abe3a6 100644
--- a/gottext/test/build.test.ts
+++ b/gottext/test/build.test.ts
@@ -597,6 +597,141 @@ 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 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"
+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('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'
+
+ 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'