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'