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
27 changes: 27 additions & 0 deletions gottext/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ 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 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 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, 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
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
Expand Down
16 changes: 8 additions & 8 deletions gottext/src/gates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

/**
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion gottext/src/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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])
}
Expand Down
32 changes: 25 additions & 7 deletions gottext/src/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
}
Expand Down Expand Up @@ -197,10 +197,16 @@ function restoring(
msgid: string,
entry: GetTextTranslation,
): void {
const arrived = ownEntry(held.translations[context], msgid)
const entries: Record<string, GetTextTranslation> = ownEntry(held.translations, context) ?? {}
Object.defineProperty(held.translations, context, {
value: entries,
writable: true,
enumerable: true,
configurable: true,
})
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
Expand Down Expand Up @@ -246,7 +252,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)
}
Expand All @@ -255,6 +261,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 > 0
&& arrived.msgstr.every((form) => form !== '')
&& entry.msgstr.every((form, at) => form === '' || arrived.msgstr[at] !== undefined)
}

/**
* Returns a catalogue without the messages an export already answers and nobody may overwrite.
* @param source - The catalogue as committed.
Expand All @@ -266,11 +284,11 @@ 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
}
if (arrived.msgstr.length > 0 && arrived.msgstr.every((form) => form !== '')) {
if (settledBy(entry, arrived)) {
delete held.translations[context][msgid]
continue
}
Expand Down
13 changes: 12 additions & 1 deletion gottext/src/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function poeditorAt(options: PlatformOptions): Poeditor & Retiring {
const paused = options.paused
?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
let uploaded = false
let queued: Promise<unknown> = Promise.resolve()
/**
* Returns the values every call carries.
* @returns The credential and the project.
Expand Down Expand Up @@ -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<NonNullable<Answer['result']>> {
async function pacedSend(form: FormData): Promise<NonNullable<Answer['result']>> {
if (uploaded) {
await paused(paced)
}
Expand All @@ -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<NonNullable<Answer['result']>> {
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.
Expand Down
15 changes: 10 additions & 5 deletions gottext/src/start.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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.
Expand All @@ -31,20 +34,22 @@ export async function startLocale(
entries: CatalogEntry[],
options: LocaleOptions = {},
): Promise<string> {
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
}
77 changes: 77 additions & 0 deletions gottext/test/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -401,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([])
})
2 changes: 1 addition & 1 deletion gottext/test/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' },
Expand Down
46 changes: 46 additions & 0 deletions gottext/test/lockfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
})
Expand All @@ -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 })
Expand Down
Loading