Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions gottext/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 31 additions & 4 deletions gottext/src/gates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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]$/

Expand Down Expand Up @@ -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.
Expand All @@ -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))
}
Expand Down
135 changes: 135 additions & 0 deletions gottext/test/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<notes>x</notes> y"\nmsgstr ""\n'
const added = 'msgid "<notes>x</notes> y"\nmsgstr "<notes>x</notes> <extra>y</extra>"\n'

expect(mismatched(added, naming)).toEqual(['<notes>x</notes> y'])
})

test('names a translation dropping a tag its message carries', () => {
const naming = 'msgid "<notes>x</notes> y"\nmsgstr ""\n'
const dropped = 'msgid "<notes>x</notes> y"\nmsgstr "<notes>x y"\n'

expect(mismatched(dropped, naming)).toEqual(['<notes>x</notes> y'])
})

test('names a translation renaming a tag its message carries', () => {
const naming = 'msgid "<notes>x</notes> y"\nmsgstr ""\n'
const renamed = 'msgid "<notes>x</notes> y"\nmsgstr "<note>x</note> y"\n'

expect(mismatched(renamed, naming)).toEqual(['<notes>x</notes> y'])
})

test('names a translation repeating a tag its message carries', () => {
const naming = 'msgid "<notes>x</notes> y"\nmsgstr ""\n'
const repeated = 'msgid "<notes>x</notes> y"\nmsgstr "<notes>x</notes> <notes>y</notes>"\n'

expect(mismatched(repeated, naming)).toEqual(['<notes>x</notes> y'])
})

test('names a translation answering an opening and a closing tag with a self closing one', () => {
const naming = 'msgid "<notes>x</notes> y"\nmsgstr ""\n'
const selfClosed = 'msgid "<notes>x</notes> y"\nmsgstr "<notes/>x y"\n'

expect(mismatched(selfClosed, naming)).toEqual(['<notes>x</notes> y'])
})

test('names a translation opening a tag its message carries self closing', () => {
const naming = 'msgid "a <br/> b"\nmsgstr ""\n'
const opened = 'msgid "a <br/> b"\nmsgstr "a <br> b"\n'

expect(mismatched(opened, naming)).toEqual(['a <br/> b'])
})

test('names a translation answering a closing tag with an opening one', () => {
const naming = 'msgid "<notes>x</notes> y"\nmsgstr ""\n'
const reopened = 'msgid "<notes>x</notes> y"\nmsgstr "<notes>x<notes> y"\n'

expect(mismatched(reopened, naming)).toEqual(['<notes>x</notes> y'])
})

test('names a translation closing a tag its message carries self closing', () => {
const naming = 'msgid "a <br/> b"\nmsgstr ""\n'
const closed = 'msgid "a <br/> b"\nmsgstr "a </br> b"\n'

expect(mismatched(closed, naming)).toEqual(['a <br/> b'])
})

test('names a translation dropping a tag whose name carries capitals and digits', () => {
const naming = 'msgid "<Link2>x</Link2> y"\nmsgstr ""\n'
const dropped = 'msgid "<Link2>x</Link2> y"\nmsgstr "x y"\n'

expect(mismatched(dropped, naming)).toEqual(['<Link2>x</Link2> y'])
})

test('names a translation whose plural form drops a tag', () => {
const naming = `msgid "<notes>one</notes> post"
msgid_plural "<notes>many</notes> posts"
msgstr[0] ""
msgstr[1] ""
`
const broken = `msgid "<notes>one</notes> post"
msgid_plural "<notes>many</notes> posts"
msgstr[0] "<notes>uno</notes> entrada"
msgstr[1] "<notes>muchas entradas"
`

expect(mismatched(broken, naming)).toEqual(['<notes>one</notes> post'])
})

test('names a translation whose singular form repeats a tag', () => {
const naming = `msgid "<notes>one</notes> post"
msgid_plural "<notes>many</notes> posts"
msgstr[0] ""
msgstr[1] ""
`
const broken = `msgid "<notes>one</notes> post"
msgid_plural "<notes>many</notes> posts"
msgstr[0] "<notes>uno</notes> <notes>uno</notes>"
msgstr[1] "<notes>muchas</notes> entradas"
`

expect(mismatched(broken, naming)).toEqual(['<notes>one</notes> post'])
})

test('reads the tags of each plural form against the form it answers', () => {
const naming = `msgid "one post"
msgid_plural "<notes>many</notes> posts"
msgstr[0] ""
msgstr[1] ""
`
const answered = `msgid "one post"
msgid_plural "<notes>many</notes> posts"
msgstr[0] "una entrada"
msgstr[1] "<notes>muchas</notes> entradas"
`

expect(mismatched(answered, naming)).toEqual([])
})

test('passes a translation moving a tag its message carries', () => {
const naming = 'msgid "<notes>x</notes> <link>y</link>"\nmsgstr ""\n'
const moved = 'msgid "<notes>x</notes> <link>y</link>"\nmsgstr "<link>y</link> <notes>x</notes>"\n'

expect(mismatched(moved, naming)).toEqual([])
})

test('passes a translation carrying spaces inside a tag', () => {
const naming = 'msgid "<notes>x</notes> y"\nmsgstr ""\n'
const spaced = 'msgid "<notes>x</notes> y"\nmsgstr "<notes >x</notes > y"\n'

expect(mismatched(spaced, naming)).toEqual([])
})

test('passes an empty form answering a message carrying tags', () => {
const naming = 'msgid "<notes>x</notes> y"\nmsgstr ""\n'

expect(mismatched(naming, naming)).toEqual([])
})

test('passes a translation closing a tag before opening it', () => {
const naming = 'msgid "<notes>x</notes> y"\nmsgstr ""\n'
const closedFirst = 'msgid "<notes>x</notes> y"\nmsgstr "</notes>x<notes> 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'

Expand Down
Loading