From 18c1d96a8ffd524fd855b5f90dedcbed1b0ad181 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 13:34:59 +0200 Subject: [PATCH 01/10] fix(gottext): keep a constructor context out of the shared prototype --- gottext/src/merge.ts | 13 +++++++------ gottext/test/sync.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/gottext/src/merge.ts b/gottext/src/merge.ts index 6354e6c..f85cc9b 100644 --- a/gottext/src/merge.ts +++ b/gottext/src/merge.ts @@ -121,7 +121,7 @@ export function namedByTemplate(incoming: string, template: string): string { const held = po.parse(incoming) for (const [context, entries] of Object.entries(held.translations)) { for (const msgid of Object.keys(entries)) { - if (msgid !== METADATA && ownEntry(named[context], msgid) === undefined) { + if (msgid !== METADATA && ownEntry(ownEntry(named, context), msgid) === undefined) { delete held.translations[context][msgid] } } @@ -197,10 +197,11 @@ function restoring( msgid: string, entry: GetTextTranslation, ): void { - const arrived = ownEntry(held.translations[context], msgid) + const entries: Record = ownEntry(held.translations, context) ?? {} + held.translations[context] = entries + const arrived = ownEntry(entries, msgid) if (arrived === undefined) { - held.translations[context] ??= {} - held.translations[context][msgid] = entry + entries[msgid] = entry return } arrived.msgid_plural ??= entry.msgid_plural @@ -246,7 +247,7 @@ export function keepingAnswers(current: string, incoming: string, template: stri const held = po.parse(incoming) for (const [context, entries] of Object.entries(ours)) { for (const [msgid, entry] of Object.entries(entries)) { - const wanted = msgid !== METADATA && ownEntry(named[context], msgid) !== undefined + const wanted = msgid !== METADATA && ownEntry(ownEntry(named, context), msgid) !== undefined if (wanted && entry.msgstr.some((form) => form !== '')) { restoring(held, context, msgid, entry) } @@ -266,7 +267,7 @@ export function withoutSettled(source: string, exported: string): string { const held = po.parse(source) for (const [context, entries] of Object.entries(held.translations)) { for (const [msgid, entry] of Object.entries(entries)) { - const arrived = ownEntry(theirs[context], msgid) + const arrived = ownEntry(ownEntry(theirs, context), msgid) if (msgid === METADATA || arrived === undefined || fuzzyOf(arrived)) { continue } diff --git a/gottext/test/sync.test.ts b/gottext/test/sync.test.ts index e673873..0aa889c 100644 --- a/gottext/test/sync.test.ts +++ b/gottext/test/sync.test.ts @@ -315,6 +315,41 @@ test('keeps an answer whose message shares its name with a prototype member', () expect(keepingAnswers(ours, theirs, naming)).toContain('msgid "constructor"') }) +test('an answer under a constructor context survives the merge and pollutes nothing', () => { + const naming = 'msgctxt "constructor"\nmsgid "Older posts"\nmsgstr ""\n' + const ours = 'msgid ""\nmsgstr ""\n\nmsgctxt "constructor"\nmsgid "Older posts"\nmsgstr "Entradas anteriores"\n' + const theirs = 'msgid ""\nmsgstr ""\n' + + const held = keepingAnswers(ours, theirs, naming) + + expect(held).toContain('msgctxt "constructor"') + expect(held).toContain('Entradas anteriores') + expect(Object.hasOwn(globalThis.Object, 'Older posts')).toBe(false) +}) + +test('drops a constructor context the template does not name', () => { + const theirs = 'msgid ""\nmsgstr ""\n\nmsgctxt "constructor"\nmsgid "name"\nmsgstr "nombre"\n' + + const held = namedByTemplate(theirs, TEMPLATE) + + expect(held).not.toContain('msgctxt "constructor"') + expect(held).not.toContain('nombre') +}) + +test('pushes an answer under a constructor context the platform lacks', async () => { + const naming = 'msgctxt "constructor"\nmsgid "name"\nmsgstr ""\n' + const ours = `${HEADER}\nmsgctxt "constructor"\nmsgid "name"\nmsgstr "nombre"\n` + const { platform, uploads } = exportingPlatform(['es'], { es: 'msgid ""\nmsgstr ""\n' }) + const { held } = storeOf({ 'es-ES': ours }) + + const done = await pushTranslations(platform, ['es-ES'], held, naming) + + expect(uploads).toHaveLength(1) + expect(uploads[0][1]).toContain('msgctxt "constructor"') + expect(uploads[0][1]).toContain('nombre') + expect(done.pushed).toEqual(['es-ES']) +}) + test('restores an answer under a context the export does not know', () => { const naming = 'msgctxt "posts"\nmsgid "Older"\nmsgstr ""\n' const ours = 'msgid ""\nmsgstr ""\n\nmsgctxt "posts"\nmsgid "Older"\nmsgstr "Antiguas"\n' From a528a3289f60f970e4e0c182fc9a89f7814d1d60 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 13:35:22 +0200 Subject: [PATCH 02/10] fix(gottext): keep every plural form when an export is flattened --- gottext/src/merge.ts | 14 +++++++++++- gottext/test/sync.test.ts | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/gottext/src/merge.ts b/gottext/src/merge.ts index f85cc9b..c5fba7f 100644 --- a/gottext/src/merge.ts +++ b/gottext/src/merge.ts @@ -256,6 +256,18 @@ export function keepingAnswers(current: string, incoming: string, template: stri return po.compile(held, COMPILED).toString() } +/** + * Reports whether an exported answer settles a committed one. + * @param entry - The committed answer. + * @param arrived - The answer the platform exported. + * @returns Whether the export answers at least as many forms as the committed entry, all filled. + */ +function settledBy(entry: GetTextTranslation, arrived: GetTextTranslation): boolean { + return arrived.msgstr.length >= entry.msgstr.length + && arrived.msgstr.length > 0 + && arrived.msgstr.every((form) => form !== '') +} + /** * Returns a catalogue without the messages an export already answers and nobody may overwrite. * @param source - The catalogue as committed. @@ -271,7 +283,7 @@ export function withoutSettled(source: string, exported: string): string { if (msgid === METADATA || arrived === undefined || fuzzyOf(arrived)) { continue } - if (arrived.msgstr.length > 0 && arrived.msgstr.every((form) => form !== '')) { + if (settledBy(entry, arrived)) { delete held.translations[context][msgid] continue } diff --git a/gottext/test/sync.test.ts b/gottext/test/sync.test.ts index 0aa889c..854b237 100644 --- a/gottext/test/sync.test.ts +++ b/gottext/test/sync.test.ts @@ -819,6 +819,53 @@ test('never pushes over a plural the platform answered in every form', async () expect(done.skipped).toEqual(['es, which the platform has settled every answer of']) }) +/** APPLE_TEMPLATE names the plural message the settling tests share. */ +const APPLE_TEMPLATE = 'msgid "one apple"\nmsgid_plural "many apples"\nmsgstr[0] ""\nmsgstr[1] ""\n' + +/** + * Returns a catalogue answering the apple message with the given forms. + * @param forms - The forms the catalogue answers. + * @returns The catalogue as PO text. + */ +function appleCatalogue(...forms: string[]): string { + const answered = forms.map((form, at) => `msgstr[${at}] "${form}"`).join('\n') + return `${HEADER}\nmsgid "one apple"\nmsgid_plural "many apples"\n${answered}\n` +} + +test('a flattened export keeps every local plural form', async () => { + const { platform, uploads } = exportingPlatform(['es'], { es: appleCatalogue('una manzana') }) + const { held } = storeOf({ 'es-ES': appleCatalogue('una manzana', 'muchas manzanas') }) + + const done = await pushTranslations(platform, ['es-ES'], held, APPLE_TEMPLATE) + + expect(uploads).toHaveLength(1) + expect(uploads[0][1]).toContain('msgstr[0] "una manzana"') + expect(uploads[0][1]).toContain('msgstr[1] "muchas manzanas"') + expect(done.pushed).toEqual(['es-ES']) +}) + +test('an export answering every form filled still settles the entry', async () => { + const { platform, uploads } = exportingPlatform(['es'], { + es: appleCatalogue('una manzana', 'muchas manzanas'), + }) + const { held } = storeOf({ 'es-ES': appleCatalogue('una manzana', 'muchas manzanas') }) + + const done = await pushTranslations(platform, ['es-ES'], held, APPLE_TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.skipped).toEqual(['es, which the platform has settled every answer of']) +}) + +test('a fully answered singular still settles', async () => { + const { platform, uploads } = exportingPlatform(['es'], { es: catalogue('Entradas revisadas') }) + const { held } = storeOf({ 'es-ES': catalogue('Entradas anteriores') }) + + const done = await pushTranslations(platform, ['es-ES'], held, TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.skipped).toEqual(['es, which the platform has settled every answer of']) +}) + test('says which supported language it holds no catalogue to add', async () => { const { platform, languagesAdded } = receivingPlatform([]) const { held } = storeOf({}) From d7bb7f3c89cbb1c62b98a53dd1c8feae98a9ecb9 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 13:37:22 +0200 Subject: [PATCH 03/10] fix(gottext): queue platform uploads behind one another --- gottext/src/platform.ts | 13 ++++++++++++- gottext/test/sync.test.ts | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/gottext/src/platform.ts b/gottext/src/platform.ts index 3b1c170..0430449 100644 --- a/gottext/src/platform.ts +++ b/gottext/src/platform.ts @@ -93,6 +93,7 @@ export function poeditorAt(options: PlatformOptions): Poeditor & Retiring { const paused = options.paused ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) let uploaded = false + let queued: Promise = Promise.resolve() /** * Returns the values every call carries. * @returns The credential and the project. @@ -136,7 +137,7 @@ export function poeditorAt(options: PlatformOptions): Poeditor & Retiring { * @param form - The upload to send. * @returns The result the platform answered with. */ - async function uploadForm(form: FormData): Promise> { + async function pacedSend(form: FormData): Promise> { if (uploaded) { await paused(paced) } @@ -153,6 +154,16 @@ export function poeditorAt(options: PlatformOptions): Poeditor & Retiring { await paused(paced) return resultOf(await send()) } + /** + * Sends one upload form behind every upload before it, so none share a pacing window. + * @param form - The upload to send. + * @returns The result the platform answered with. + */ + function uploadForm(form: FormData): Promise> { + const held = queued.then(() => pacedSend(form)) + queued = held.catch(() => undefined) + return held + } /** * Sends the template to the platform, saying whether absent terms retire. * @param source - The catalogue template as POT text. diff --git a/gottext/test/sync.test.ts b/gottext/test/sync.test.ts index 854b237..9a03718 100644 --- a/gottext/test/sync.test.ts +++ b/gottext/test/sync.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { expect, test, vi } from 'vitest' +import { afterEach, expect, test, vi } from 'vitest' import { answeredForms, @@ -17,6 +17,10 @@ import { } from '../src/sync.js' import type { Catalogues, Poeditor } from '../src/sync.js' +afterEach(() => { + vi.unstubAllGlobals() +}) + const HEADER = `msgid "" msgstr "" "Language: es-ES\\n" @@ -521,7 +525,6 @@ test('sends through the runtime fetch unless it is handed one', async () => { const held = await poeditorAt({ token: 't', project: 'p', domain: 'probe' }).languages() expect(held).toEqual([]) - vi.unstubAllGlobals() }) test('refuses a platform answer that did not arrive', async () => { @@ -1022,3 +1025,35 @@ test('waits between uploads through the runtime clock unless handed a pause', as expect(fetched).toHaveBeenCalledTimes(2) }) + +afterEach(() => { + vi.useRealTimers() +}) + +test('a concurrent upload waits a full pacing window behind the send before it', async () => { + vi.useFakeTimers() + let asked = 0 + const fetched = vi.fn(async () => { + asked += 1 + return asked === 1 + ? new Response(JSON.stringify({ response: { status: 'fail', code: '4048', message: 'slow down' } })) + : new Response(JSON.stringify({ response: { status: 'success' }, result: {} })) + }) as unknown as typeof fetch + const platform = poeditorAt({ token: 't', project: 'p', domain: 'probe', fetched, paced: 1000 }) + + const first = platform.uploadTerms(TEMPLATE) + const second = platform.uploadTranslations('es', catalogue('Entradas')) + await vi.advanceTimersByTimeAsync(0) + + expect(fetched).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1000) + + expect(fetched).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(1000) + + expect(fetched).toHaveBeenCalledTimes(3) + await first + await second +}) From c4694f56c81efd49a5e7abafabb53858829aa603 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 13:37:40 +0200 Subject: [PATCH 04/10] fix(gottext): make the locale start transactional --- gottext/src/start.ts | 15 +++-- gottext/test/runtime.test.ts | 117 ++++++++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 14 deletions(-) diff --git a/gottext/src/start.ts b/gottext/src/start.ts index 93ffd46..6f2e16b 100644 --- a/gottext/src/start.ts +++ b/gottext/src/start.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { setLocaleData } from '@wordpress/i18n' +import { resetLocaleData, setLocaleData } from '@wordpress/i18n' import type { Catalog } from './catalog.js' import { rememberLocale } from './display.js' @@ -19,8 +19,11 @@ export interface LocaleOptions { defaultLocale?: string } +/** newest numbers the latest start so one it superseded can drop its results. */ +let newest = 0 + /** - * Resolves the locale, loads every catalogue in parallel, and sets each under its domain. + * Resolves the locale, loads every catalogue in parallel, and commits catalogues and locale as one transaction. * @param resolve - Answers the locale the interface should stand in. * @param entries - The text domains to load. * @param options - What the start needs beyond its domains. @@ -31,20 +34,22 @@ export async function startLocale( entries: CatalogEntry[], options: LocaleOptions = {}, ): Promise { + const started = ++newest if (options.defaultLocale !== undefined) { rememberLocale(options.defaultLocale) } const locale = await resolve() - rememberLocale(locale) - if (locale === options.defaultLocale) { + const loaded = locale === options.defaultLocale ? [] : await Promise.all(entries.map((entry) => entry.load(locale))) + if (started !== newest) { return locale } - const loaded = await Promise.all(entries.map((entry) => entry.load(locale))) + resetLocaleData() entries.forEach((entry, at) => { const catalog = loaded[at] if (catalog !== undefined) { setLocaleData(catalog, entry.domain) } }) + rememberLocale(locale) return locale } diff --git a/gottext/test/runtime.test.ts b/gottext/test/runtime.test.ts index 34b6231..b0f4e61 100644 --- a/gottext/test/runtime.test.ts +++ b/gottext/test/runtime.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { __, getLocaleData } from '@wordpress/i18n' +import { __, getLocaleData, resetLocaleData, setLocaleData } from '@wordpress/i18n' import { expect, test } from 'vitest' import { displayLocale, rememberLocale, startLocale } from '../src/index.js' @@ -47,14 +47,6 @@ test('sets a loaded catalogue under its domain before returning', async () => { expect(__('Older posts', 'gottext-probe')).toBe('Entradas anteriores') }) -test('leaves a domain untouched when its loader answers nothing', async () => { - await startLocale(async () => 'es-ES', [ - { domain: 'gottext-empty', load: async () => undefined }, - ]) - - expect(__('Older posts', 'gottext-empty')).toBe('Older posts') -}) - test('loads nothing for the locale the sources are written in', async () => { const asked: string[] = [] @@ -98,3 +90,110 @@ test('sets every catalogue it is handed', async () => { expect(__('Older posts', 'gottext-one')).toBe('Entradas anteriores') expect(__('Older posts', 'gottext-two')).toBe('Entradas anteriores') }) + +test('keeps the display locale while a catalogue is still loading', async () => { + rememberLocale('en-US') + let release: (catalog: Catalog) => void = () => {} + const pending = startLocale(async () => 'es-ES', [ + { domain: 'gottext-slow', load: () => new Promise((resolve) => { release = resolve }) }, + ]) + await Promise.resolve() + await Promise.resolve() + + expect(displayLocale()).toBe('en-US') + + release(CATALOG) + await pending + expect(displayLocale()).toBe('es-ES') +}) + +test('keeps the display locale when a loader refuses', async () => { + rememberLocale('en-US') + const refusal = new Error('the catalogue endpoint refused') + const pending = startLocale(async () => 'de-DE', [ + { domain: 'gottext-broken', load: () => Promise.reject(refusal) }, + ]) + + await expect(pending).rejects.toBe(refusal) + expect(displayLocale()).toBe('en-US') +}) + +test('a superseded start drops its catalogues and locale commit', async () => { + let releaseOlder: (catalog: Catalog) => void = () => {} + const olderCatalog: Catalog = { + '': { lang: 'de-DE', 'plural-forms': 'nplurals=2; plural=(n != 1);' }, + 'Older posts': ['answered by the older start'], + } + const older = startLocale(async () => 'de-DE', [ + { domain: 'gottext-race', load: () => new Promise((resolve) => { releaseOlder = resolve }) }, + ]) + const newerCatalog: Catalog = { + '': { lang: 'fr-FR', 'plural-forms': 'nplurals=2; plural=(n != 1);' }, + 'Older posts': ['answered by the newer start'], + } + const newer = startLocale(async () => 'fr-FR', [ + { domain: 'gottext-race', load: async () => newerCatalog }, + ]) + + await newer + releaseOlder(olderCatalog) + await older + + expect(__('Older posts', 'gottext-race')).toBe('answered by the newer start') + expect(displayLocale()).toBe('fr-FR') +}) + +test('a reset naming one domain clears every loaded domain', () => { + setLocaleData(CATALOG, 'gottext-reset-named') + setLocaleData(CATALOG, 'gottext-reset-other') + resetLocaleData({}, 'gottext-reset-named') + + expect(__('Older posts', 'gottext-reset-named')).toBe('Older posts') + expect(__('Older posts', 'gottext-reset-other')).toBe('Older posts') +}) + +test('a switch clears a domain whose loader answers nothing', async () => { + await startLocale(async () => 'es-ES', [ + { domain: 'gottext-stale', load: async () => CATALOG }, + ]) + await startLocale(async () => 'de-DE', [ + { domain: 'gottext-stale', load: async () => undefined }, + ]) + + expect(__('Older posts', 'gottext-stale')).toBe('Older posts') +}) + +test('a switch to the default locale clears every configured domain', async () => { + await startLocale(async () => 'es-ES', [ + { domain: 'gottext-home-one', load: async () => CATALOG }, + { domain: 'gottext-home-two', load: async () => CATALOG }, + ]) + await startLocale( + async () => 'en-US', + [ + { domain: 'gottext-home-one', load: async () => CATALOG }, + { domain: 'gottext-home-two', load: async () => CATALOG }, + ], + { defaultLocale: 'en-US' }, + ) + + expect(__('Older posts', 'gottext-home-one')).toBe('Older posts') + expect(__('Older posts', 'gottext-home-two')).toBe('Older posts') +}) + +test('a switch replaces a catalogue rather than merging into it', async () => { + const fuller: Catalog = { + '': { lang: 'es-ES', 'plural-forms': 'nplurals=2; plural=(n != 1);' }, + 'Older posts': ['Entradas anteriores'], + 'Newer posts': ['Entradas siguientes'], + } + const narrower: Catalog = { + '': { lang: 'es-ES', 'plural-forms': 'nplurals=2; plural=(n != 1);' }, + 'Older posts': ['Entradas anteriores'], + } + await startLocale(async () => 'es-ES', [{ domain: 'gottext-swap', load: async () => fuller }]) + await startLocale(async () => 'es-ES', [{ domain: 'gottext-swap', load: async () => narrower }]) + + expect(__('Newer posts', 'gottext-swap')).toBe('Newer posts') + expect(__('Older posts', 'gottext-swap')).toBe('Entradas anteriores') +}) From e6079bfc06a255c5605f2c88cac45f6c5f8d2897 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 13:38:00 +0200 Subject: [PATCH 05/10] fix(gottext): read printf modifiers in the placeholder gate --- gottext/src/gates.ts | 14 ++++++------- gottext/test/build.test.ts | 42 +++++++++++++++++++++++++++++++++++++ gottext/test/errors.test.ts | 2 +- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/gottext/src/gates.ts b/gottext/src/gates.ts index fea7493..aaf3d35 100644 --- a/gottext/src/gates.ts +++ b/gottext/src/gates.ts @@ -77,19 +77,19 @@ export function unreviewed(source: string): string[] { return waiting } -/** NAMED is a placeholder naming what goes into it. */ -const NAMED = /%\(([A-Za-z_][A-Za-z0-9_]*)\)[bcdieEfgGosuxX]/g +/** NAMED is a placeholder naming what goes into it, with any flags, width, and precision it carries. */ +const NAMED = /%\(([A-Za-z_][A-Za-z0-9_]*)\)[ +0#-]*\d*(?:\.\d+)?[bcdieEfgGosuxX]/g -/** BARE is a placeholder naming nothing. */ -const BARE = /%(?:\d+\$)?[bcdieEfgGosuxX]/g +/** BARE is a placeholder naming nothing, with any flags, width, and precision it carries. */ +const BARE = /%(?:\d+\$)?[ +0#-]*\d*(?:\.\d+)?[bcdieEfgGosuxX]/g /** - * Returns the placeholders a message names, each once. + * Returns the named placeholders a message carries, each once and whole. * @param message - The message to read. - * @returns The names, in a settled order. + * @returns The placeholders, in a settled order. */ function placeholders(message: string): string[] { - return [...new Set([...message.matchAll(NAMED)].map((found) => found[1]))].sort() + return [...new Set([...message.matchAll(NAMED)].map((found) => found[0]))].sort() } /** diff --git a/gottext/test/build.test.ts b/gottext/test/build.test.ts index 69cf33e..1f978a7 100644 --- a/gottext/test/build.test.ts +++ b/gottext/test/build.test.ts @@ -300,6 +300,48 @@ test('passes a translation reordering the positional placeholders its message na expect(mismatched(answered, naming)).toEqual([]) }) +test('names a translation dropping a zero padded placeholder', () => { + const naming = 'msgid "Post %02d"\nmsgstr ""\n' + const dropped = 'msgid "Post %02d"\nmsgstr "Entrada"\n' + + expect(mismatched(dropped, naming)).toEqual(['Post %02d']) +}) + +test('names a translation dropping a precision placeholder', () => { + const naming = 'msgid "Held %.2f"\nmsgstr ""\n' + const dropped = 'msgid "Held %.2f"\nmsgstr "Retenido"\n' + + expect(mismatched(dropped, naming)).toEqual(['Held %.2f']) +}) + +test('names a translation dropping a named placeholder carrying width and precision', () => { + const naming = 'msgid "You hold %(held)05.2f."\nmsgstr ""\n' + const dropped = 'msgid "You hold %(held)05.2f."\nmsgstr "Nada retenido."\n' + + expect(mismatched(dropped, naming)).toEqual(['You hold %(held)05.2f.']) +}) + +test('names a translation widening a padded placeholder', () => { + const naming = 'msgid "Post %02d"\nmsgstr ""\n' + const widened = 'msgid "Post %02d"\nmsgstr "Entrada %03d"\n' + + expect(mismatched(widened, naming)).toEqual(['Post %02d']) +}) + +test('names a translation changing the precision a named placeholder asks for', () => { + const naming = 'msgid "You hold %(held)05.2f."\nmsgstr ""\n' + const changed = 'msgid "You hold %(held)05.2f."\nmsgstr "Retienes %(held)05.3f."\n' + + expect(mismatched(changed, naming)).toEqual(['You hold %(held)05.2f.']) +}) + +test('passes a translation keeping every modified placeholder whole', () => { + const naming = 'msgid "You hold %(held)05.2f of %.1f."\nmsgstr ""\n' + const answered = 'msgid "You hold %(held)05.2f of %.1f."\nmsgstr "Retienes %(held)05.2f de %.1f."\n' + + expect(mismatched(answered, naming)).toEqual([]) +}) + test('names a message that shares its name with a prototype member', () => { const naming = 'msgid "Older posts"\nmsgstr ""\n' const stale = 'msgid ""\nmsgstr ""\n\nmsgid "constructor"\nmsgstr "constructor"\n' diff --git a/gottext/test/errors.test.ts b/gottext/test/errors.test.ts index f345a15..5826e60 100644 --- a/gottext/test/errors.test.ts +++ b/gottext/test/errors.test.ts @@ -70,7 +70,7 @@ test('speaks the answer when a template asking for a decimal lacks its value', ( expect(shown).toBe('the balance is short') }) -test('fills a template asking for widths and decimals', () => { +test('fills a template rounding each value to the decimals it asks for', () => { const shown = errorText( { message: 'raw', code: 'short', meta: { held: 9, needed: 12.5 } }, { short: 'You hold %(held)05.2f of the %(needed).1f asked for.' }, From 7e3ced865790d9df37635035541b28d5bdc427d8 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 13:38:37 +0200 Subject: [PATCH 06/10] fix(gottext): read peer qualified lockfile keys --- gottext/src/lockfile.ts | 2 +- gottext/test/lockfile.test.ts | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/gottext/src/lockfile.ts b/gottext/src/lockfile.ts index 5634224..862a474 100644 --- a/gottext/src/lockfile.ts +++ b/gottext/src/lockfile.ts @@ -15,7 +15,7 @@ export function resolvedVersions(lockfile: string, name: string): string[] { const packages = lockfile.slice(start, end === -1 ? undefined : end) const found = new Set() for (const line of packages.split('\n')) { - const match = /^ {2}'?(.+)@([^@']+)'?:$/.exec(line) + const match = /^ {2}'?(.+)@([^@'()]+)(?:\([^)]+\))*'?:$/.exec(line) if (match && match[1] === name) { found.add(match[2]) } diff --git a/gottext/test/lockfile.test.ts b/gottext/test/lockfile.test.ts index c4704fe..e986f3d 100644 --- a/gottext/test/lockfile.test.ts +++ b/gottext/test/lockfile.test.ts @@ -21,6 +21,36 @@ snapshots: '@wordpress/i18n@9.9.9': {} ` +const PEER_LOCKFILE = ` +packages: + + 'plain@1.2.3': + resolution: {integrity: sha512-c} + + '@scoped/tool@4.10.1(eslint@9.18.0)(typescript@5.7.3)': + resolution: {integrity: sha512-d} + + peer-tool@2.0.0(eslint@9.18.0): + resolution: {integrity: sha512-e} + +snapshots: + + 'plain@9.9.9': {} +` + +const SPLIT_LOCKFILE = ` +packages: + + '@scoped/tool@4.10.1(eslint@9.18.0)(typescript@5.7.3)': + resolution: {integrity: sha512-f} + + '@scoped/tool@4.10.1(eslint@8.57.0)': + resolution: {integrity: sha512-g} + + '@scoped/tool@4.11.0(eslint@9.18.0)': + resolution: {integrity: sha512-h} +` + test('names every version a lockfile resolves for a package', () => { expect(resolvedVersions(LOCKFILE, '@wordpress/i18n')).toEqual(['6.26.0']) }) @@ -39,6 +69,22 @@ test('reads a lockfile whose packages run to the end', () => { expect(resolvedVersions(held, 'left-pad')).toEqual(['1.3.0']) }) +test('a peer qualified scoped key answers its bare version', () => { + expect(resolvedVersions(PEER_LOCKFILE, '@scoped/tool')).toEqual(['4.10.1']) +}) + +test('a peer qualified unscoped key answers its bare version', () => { + expect(resolvedVersions(PEER_LOCKFILE, 'peer-tool')).toEqual(['2.0.0']) +}) + +test('a plain key beside peer qualified keys keeps its version', () => { + expect(resolvedVersions(PEER_LOCKFILE, 'plain')).toEqual(['1.2.3']) +}) + +test('a package resolved under different peer sets answers each bare version once', () => { + expect(resolvedVersions(SPLIT_LOCKFILE, '@scoped/tool')).toEqual(['4.10.1', '4.11.0']) +}) + test('names the version each package pins', () => { const root = mkdtempSync(join(tmpdir(), 'gottext-lock-')) mkdirSync(join(root, 'app'), { recursive: true }) From 5ac15a551eebe40eb230f481c5dc15d5da8762e5 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 13:38:59 +0200 Subject: [PATCH 07/10] docs(gottext): record the sweep under Unreleased --- gottext/CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/gottext/CHANGELOG.md b/gottext/CHANGELOG.md index 29c495d..f2446ad 100644 --- a/gottext/CHANGELOG.md +++ b/gottext/CHANGELOG.md @@ -5,6 +5,28 @@ 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 + +- A catalogue entry under the context named constructor survives every + merge instead of vanishing onto the global Object, and pushing one + no longer crashes when the platform lacks the context. +- A flattened export keeps every local plural form in the push, an + entry only settles when the export answers at least the forms the + committed catalogue holds. +- The placeholder gate reads printf modifiers, so a translation + dropping or changing forms such as %02d, %.2f or a named placeholder + carrying width and precision is caught as mismatched. +- The locale start is transactional, the display locale commits only + after every catalogue loads, a superseded start drops its results, + and every switch replaces each domain's catalogue instead of + merging stale entries. +- Peer qualified lockfile keys answer their bare versions, so the + duplicate resolution gate sees packages resolved with peer suffixes. +- Concurrent platform uploads queue behind each other, delay and + retry included, so they cannot share one pacing window. + ## [0.3.0] - 2026-08-25 ### Added From 3af534541a15b40ced2c27803a329fa1e3f28567 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 14:06:01 +0200 Subject: [PATCH 08/10] fix(gottext): read an escaped percent and a missing context safely --- gottext/src/gates.ts | 6 +++--- gottext/test/build.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/gottext/src/gates.ts b/gottext/src/gates.ts index aaf3d35..18d6ff7 100644 --- a/gottext/src/gates.ts +++ b/gottext/src/gates.ts @@ -78,10 +78,10 @@ export function unreviewed(source: string): string[] { } /** NAMED is a placeholder naming what goes into it, with any flags, width, and precision it carries. */ -const NAMED = /%\(([A-Za-z_][A-Za-z0-9_]*)\)[ +0#-]*\d*(?:\.\d+)?[bcdieEfgGosuxX]/g +const NAMED = /%%|%\(([A-Za-z_][A-Za-z0-9_]*)\)[+0#-]*\d*(?:\.\d+)?[bcdieEfgGosuxX]/g /** BARE is a placeholder naming nothing, with any flags, width, and precision it carries. */ -const BARE = /%(?:\d+\$)?[ +0#-]*\d*(?:\.\d+)?[bcdieEfgGosuxX]/g +const BARE = /%%|%(?:\d+\$)?[+0#-]*\d*(?:\.\d+)?[bcdieEfgGosuxX]/g /** * Returns the named placeholders a message carries, each once and whole. @@ -135,7 +135,7 @@ export function mismatched(source: string, template: string): string[] { const broken: string[] = [] for (const [context, entries] of Object.entries(po.parse(template).translations)) { for (const [msgid, entry] of Object.entries(entries)) { - const answer = held(carried[context], msgid) + const answer = held(held(carried, context), msgid) if (msgid === METADATA || answer === undefined) { continue } diff --git a/gottext/test/build.test.ts b/gottext/test/build.test.ts index 1f978a7..5709aa1 100644 --- a/gottext/test/build.test.ts +++ b/gottext/test/build.test.ts @@ -443,3 +443,38 @@ test('sees a fuzzy answer through the mismatched gate', () => { expect(mismatched(fuzzyBroken, naming)).toEqual(['%(count)d post']) }) + +test('leaves a message carrying a literal percent alone', () => { + const naming = 'msgid "100% done"\nmsgstr ""\n' + const answered = 'msgid "100% done"\nmsgstr "100% hecho"\n' + + expect(mismatched(answered, naming)).toEqual([]) +}) + +test('leaves a message carrying a literal percent before a letter alone', () => { + const naming = 'msgid "50% off today"\nmsgstr ""\n' + const answered = 'msgid "50% off today"\nmsgstr "50% de descuento hoy"\n' + + expect(mismatched(answered, naming)).toEqual([]) +}) + +test('names a translation reading an escaped percent as a placeholder', () => { + const naming = 'msgid "Post %%02d"\nmsgstr ""\n' + const broken = 'msgid "Post %%02d"\nmsgstr "Entrada %02d"\n' + + expect(mismatched(broken, naming)).toEqual(['Post %%02d']) +}) + +test('leaves a translation keeping an escaped percent alone', () => { + const naming = 'msgid "Save %%s now"\nmsgstr ""\n' + const answered = 'msgid "Save %%s now"\nmsgstr "Guarda %%s ahora"\n' + + expect(mismatched(answered, naming)).toEqual([]) +}) + +test('reads a message under a context sharing a prototype member name', () => { + const naming = 'msgctxt "constructor"\nmsgid "name"\nmsgstr ""\n' + const unanswered = 'msgid ""\nmsgstr ""\n' + + expect(mismatched(unanswered, naming)).toEqual([]) +}) From 66991d624a9e546761a969108c3110c5a0cfc51b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 14:06:14 +0200 Subject: [PATCH 09/10] fix(gottext): restore a proto context and settle a partly filled plural --- gottext/src/merge.ts | 11 ++++++++--- gottext/test/sync.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/gottext/src/merge.ts b/gottext/src/merge.ts index c5fba7f..ad3dc9a 100644 --- a/gottext/src/merge.ts +++ b/gottext/src/merge.ts @@ -198,7 +198,12 @@ function restoring( entry: GetTextTranslation, ): void { const entries: Record = ownEntry(held.translations, context) ?? {} - held.translations[context] = entries + Object.defineProperty(held.translations, context, { + value: entries, + writable: true, + enumerable: true, + configurable: true, + }) const arrived = ownEntry(entries, msgid) if (arrived === undefined) { entries[msgid] = entry @@ -263,9 +268,9 @@ export function keepingAnswers(current: string, incoming: string, template: stri * @returns Whether the export answers at least as many forms as the committed entry, all filled. */ function settledBy(entry: GetTextTranslation, arrived: GetTextTranslation): boolean { - return arrived.msgstr.length >= entry.msgstr.length - && arrived.msgstr.length > 0 + return arrived.msgstr.length > 0 && arrived.msgstr.every((form) => form !== '') + && entry.msgstr.every((form, at) => form === '' || arrived.msgstr[at] !== undefined) } /** diff --git a/gottext/test/sync.test.ts b/gottext/test/sync.test.ts index 9a03718..c638eee 100644 --- a/gottext/test/sync.test.ts +++ b/gottext/test/sync.test.ts @@ -331,6 +331,17 @@ test('an answer under a constructor context survives the merge and pollutes noth expect(Object.hasOwn(globalThis.Object, 'Older posts')).toBe(false) }) +test('an answer under a proto context survives the merge', () => { + const naming = 'msgctxt "__proto__"\nmsgid "Older posts"\nmsgstr ""\n' + const ours = 'msgid ""\nmsgstr ""\n\nmsgctxt "__proto__"\nmsgid "Older posts"\nmsgstr "Entradas anteriores"\n' + const theirs = 'msgid ""\nmsgstr ""\n' + + const held = keepingAnswers(ours, theirs, naming) + + expect(held).toContain('msgctxt "__proto__"') + expect(held).toContain('Entradas anteriores') +}) + test('drops a constructor context the template does not name', () => { const theirs = 'msgid ""\nmsgstr ""\n\nmsgctxt "constructor"\nmsgid "name"\nmsgstr "nombre"\n' @@ -859,6 +870,18 @@ test('an export answering every form filled still settles the entry', async () = expect(done.skipped).toEqual(['es, which the platform has settled every answer of']) }) +test('settles a plural whose committed catalogue leaves a form empty', async () => { + const { platform, uploads } = exportingPlatform(['es'], { + es: appleCatalogue('una manzana', 'muchas manzanas'), + }) + const { held } = storeOf({ 'es-ES': appleCatalogue('una manzana', 'muchas manzanas', '') }) + + const done = await pushTranslations(platform, ['es-ES'], held, APPLE_TEMPLATE) + + expect(uploads).toHaveLength(0) + expect(done.skipped).toEqual(['es, which the platform has settled every answer of']) +}) + test('a fully answered singular still settles', async () => { const { platform, uploads } = exportingPlatform(['es'], { es: catalogue('Entradas revisadas') }) const { held } = storeOf({ 'es-ES': catalogue('Entradas anteriores') }) From f54d0695efa391037d314a0543ef9a0fa4dfec88 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 1 Sep 2026 14:06:27 +0200 Subject: [PATCH 10/10] docs(gottext): record the review fixes under Unreleased --- gottext/CHANGELOG.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/gottext/CHANGELOG.md b/gottext/CHANGELOG.md index f2446ad..5be6433 100644 --- a/gottext/CHANGELOG.md +++ b/gottext/CHANGELOG.md @@ -9,20 +9,25 @@ from CI. ### Fixed -- A catalogue entry under the context named constructor survives every - merge instead of vanishing onto the global Object, and pushing one - no longer crashes when the platform lacks the context. +- A catalogue entry under a "constructor"-named or "__proto__"-named + context survives every merge instead of vanishing onto the global + Object, pushing one no longer crashes when the platform lacks the + context, and the placeholder gate no longer crashes reading a + template context the catalogue does not answer. - A flattened export keeps every local plural form in the push, an - entry only settles when the export answers at least the forms the - committed catalogue holds. + entry only settles when the export answers every form the committed + catalogue fills, so a locally empty form no longer holds a settled + locale pending forever. - The placeholder gate reads printf modifiers, so a translation dropping or changing forms such as %02d, %.2f or a named placeholder - carrying width and precision is caught as mismatched. + carrying width and precision is caught as mismatched, while an + escaped percent and ordinary prose carrying a literal percent are + left alone. - The locale start is transactional, the display locale commits only after every catalogue loads, a superseded start drops its results, and every switch replaces each domain's catalogue instead of merging stale entries. -- Peer qualified lockfile keys answer their bare versions, so the +- Peer-qualified lockfile keys answer their bare versions, so the duplicate resolution gate sees packages resolved with peer suffixes. - Concurrent platform uploads queue behind each other, delay and retry included, so they cannot share one pacing window.