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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ jobs:
- run: pnpm run lint
- run: pnpm exec knip
- run: pnpm run build
- run: node -e "import('./dist/index.js').then(() => console.log('dist loads'))"
- run: node -e "Promise.all(['@gopherium/gottext', '@gopherium/gottext/build', '@gopherium/gottext/sync', '@gopherium/gottext/testing'].map((entry) => import(entry))).then(() => console.log('every entry loads'))"
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
versioning follows [Semantic Versioning](https://semver.org/). While at 0.x,
minor releases may break. Releases are tagged `vX.Y.Z` and publish from CI.

## [Unreleased]

### Added

- `errorText`, which shows a refused answer in the reader's own language,
filling the template its code names from the values the answer carries and
speaking the server's own words when no template fits. The caller supplies
the templates and the words to fall back on, so both stay translatable.
- A `./testing` entry with `resetLocale`, which takes every named domain and
the display locale back to their sources between tests.
- `globCatalogs`, which turns the lazy chunks a bundler globbed into the loader
a catalogue entry wants, reading the locale from each file's own name.

### Changed

- `startLocale` shows the default locale from the moment it is asked, so
`displayLocale` and `formatDate` never answer en-US to a consumer whose
sources are written in another locale.

## [0.1.1] - 2026-08-21

### Added
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
"types": "./dist/sync.d.ts",
"default": "./dist/sync.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
},
"./package.json": "./package.json"
},
"scripts": {
Expand Down
30 changes: 30 additions & 0 deletions src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ export type Catalog = Record<string, string[] | Record<string, string>>
/** METADATA is the entry carrying a catalogue's headers rather than a message. */
export const METADATA = ''

/** Chunks are the lazy catalogue modules a bundler produced, keyed by path. */
export type Chunks = Record<string, () => Promise<{ default: Catalog }>>

/**
* Returns the file name a path ends in, without its extension.
* @param path - The path a bundler keyed a chunk under.
* @returns The stem naming the locale.
*/
function stemOf(path: string): string {
const name = path.slice(path.lastIndexOf('/') + 1)
const dot = name.lastIndexOf('.')
return dot === -1 ? name : name.slice(0, dot)
}

/**
* Returns the loader answering the catalogue a locale names among the chunks.
* @param chunks - The lazy catalogue modules a bundler produced.
* @returns A loader answering one locale's catalogue, or nothing when none ships.
*/
export function globCatalogs(chunks: Chunks): (locale: string) => Promise<Catalog | undefined> {
const byLocale = new Map<string, () => Promise<{ default: Catalog }>>()
for (const [path, load] of Object.entries(chunks)) {
byLocale.set(stemOf(path), load)
}
return async (locale: string): Promise<Catalog | undefined> => {
const load = byLocale.get(locale)
return load === undefined ? undefined : (await load()).default
}
}

/**
* Returns the entry a table holds under a key, ignoring anything it inherits.
* @param table - The table to read, or nothing when the catalogue lacks it.
Expand Down
55 changes: 55 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: Apache-2.0

import { sprintf } from '@wordpress/i18n'

import { held } from './catalog.js'

/** RefusedAnswer is what a server answered when it turned a request away. */
export interface RefusedAnswer {
/** message is the server's own prose, empty when it said nothing readable. */
message: string
/** code is the stable name of the condition, absent when the server named none. */
code?: string
/** meta carries the named values the condition's message speaks about. */
meta?: Record<string, unknown>
}

/** PLACEHOLDERS matches the named places a template asks the answer to fill in, as sprintf reads them. */
const PLACEHOLDERS =
/%\(([$_a-zA-Z][$_a-zA-Z0-9]*)\)[ +0#-]*\d*(?:\.(?:\d+|\*))?(?:ll|[lhqL])?[cduxXefgsp]/g

/**
* Reports whether the answer carries every value the template names.
* @param template - The message to fill in.
* @param meta - The data the answer carries.
* @returns True when nothing the template asks for is missing.
*/
function filled(template: string, meta: Record<string, unknown>): boolean {
for (const match of template.matchAll(PLACEHOLDERS)) {
if (held(meta, match[1]) === undefined) {
return false
}
}
return true
}

/**
* Returns the message a reader is shown for a refused request, in their own language.
* @param refused - What the server answered.
* @param templates - The translated message each code stands for.
* @param fallback - The translated words to show when the answer says nothing readable.
* @returns The message to show.
*/
export function errorText(
refused: RefusedAnswer,
templates: Record<string, string>,
fallback: string,
): string {
const spoken = refused.message === '' ? fallback : refused.message
const template = refused.code === undefined ? undefined : held(templates, refused.code)
const meta = refused.meta ?? {}
if (template === undefined || !filled(template, meta)) {
return spoken
}
return sprintf(template, meta as never)
}
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@
export { displayLocale, formatDate, rememberLocale } from './display.js'
export { startLocale } from './start.js'
export type { CatalogEntry, LocaleOptions } from './start.js'
export type { Catalog } from './catalog.js'
export { errorText } from './errors.js'
export type { RefusedAnswer } from './errors.js'
export { globCatalogs } from './catalog.js'
export type { Catalog, Chunks } from './catalog.js'
5 changes: 4 additions & 1 deletion src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface CatalogEntry {

/** LocaleOptions carries what a start needs beyond its domains. */
export interface LocaleOptions {
/** defaultLocale is the locale the sources are written in, which loads no catalogue. */
/** defaultLocale is the sources' own locale, shown while the resolver runs and loading no catalogue of its own. */
defaultLocale?: string
}

Expand All @@ -31,6 +31,9 @@ export async function startLocale(
entries: CatalogEntry[],
options: LocaleOptions = {},
): Promise<string> {
if (options.defaultLocale !== undefined) {
rememberLocale(options.defaultLocale)
}
const locale = await resolve()
rememberLocale(locale)
if (locale === options.defaultLocale) {
Expand Down
14 changes: 14 additions & 0 deletions src/testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0

import { resetLocaleData } from '@wordpress/i18n'

import { rememberLocale } from './display.js'

/**
* Takes every text domain and the display locale back to their sources between tests.
* @param defaultLocale - The locale to settle back on, en-US absent a choice.
*/
export function resetLocale(defaultLocale = 'en-US'): void {
resetLocaleData({})
rememberLocale(defaultLocale)
}
60 changes: 60 additions & 0 deletions test/catalogs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: Apache-2.0

import { expect, test, vi } from 'vitest'

import { globCatalogs } from '../src/index.js'
import type { Catalog } from '../src/index.js'

const SPANISH: Catalog = {
'': { lang: 'es-ES', 'plural-forms': 'nplurals=2; plural=(n != 1);' },
'Older posts': ['Entradas anteriores'],
}

const FRENCH: Catalog = {
'': { lang: 'fr-FR', 'plural-forms': 'nplurals=2; plural=(n > 1);' },
'Older posts': ['Articles precedents'],
}

test('answers the catalogue whose file the locale names', async () => {
const load = globCatalogs({
'../languages/es-ES.json': async () => ({ default: SPANISH }),
'../languages/fr-FR.json': async () => ({ default: FRENCH }),
})

expect(await load('es-ES')).toBe(SPANISH)
expect(await load('fr-FR')).toBe(FRENCH)
})

test('answers nothing for a locale shipping no catalogue', async () => {
const load = globCatalogs({ '../languages/es-ES.json': async () => ({ default: SPANISH }) })

expect(await load('de-DE')).toBeUndefined()
})

test('reads the file stem however deep the path runs', async () => {
const load = globCatalogs({
'/srv/app/frontend/src/languages/editor/es-ES.json': async () => ({ default: SPANISH }),
})

expect(await load('es-ES')).toBe(SPANISH)
})

test('reads a whole path ending in no extension', async () => {
const load = globCatalogs({ '../languages/es-ES': async () => ({ default: SPANISH }) })

expect(await load('es-ES')).toBe(SPANISH)
})

test('leaves every other catalogue unread', async () => {
const spanish = vi.fn(async () => ({ default: SPANISH }))
const french = vi.fn(async () => ({ default: FRENCH }))
const load = globCatalogs({
'../languages/es-ES.json': spanish,
'../languages/fr-FR.json': french,
})

await load('es-ES')

expect(spanish).toHaveBeenCalledOnce()
expect(french).not.toHaveBeenCalled()
})
111 changes: 111 additions & 0 deletions test/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: Apache-2.0

import { expect, test } from 'vitest'

import { errorText } from '../src/index.js'

const TEMPLATES = {
first_out_of_range: 'Ask for between %(min)d and %(max)d at a time.',
name_taken: 'That name is already taken.',
locale_unknown: 'AlphOne does not speak %(wanted)s yet.',
}

const FALLBACK = 'Something went wrong. Try again.'

test('fills the template its code names with the data the answer carries', () => {
const shown = errorText(
{ message: 'graph: first must be between 1 and 200', code: 'first_out_of_range', meta: { min: 1, max: 200 } },
TEMPLATES,
FALLBACK,
)

expect(shown).toBe('Ask for between 1 and 200 at a time.')
})

test('shows a template naming no values as it stands', () => {
const shown = errorText({ message: 'fields: name taken', code: 'name_taken' }, TEMPLATES, FALLBACK)

expect(shown).toBe('That name is already taken.')
})

test('speaks the answer when no template holds its code', () => {
const shown = errorText(
{ message: 'the server said something new', code: 'a_code_from_the_future' },
TEMPLATES,
FALLBACK,
)

expect(shown).toBe('the server said something new')
})

test('speaks the answer when it names no code at all', () => {
const shown = errorText({ message: 'the server answered 500' }, TEMPLATES, FALLBACK)

expect(shown).toBe('the server answered 500')
})

test('speaks the answer when the template asks for a value the answer lacks', () => {
const shown = errorText(
{ message: 'graph: first must be between 1 and 200', code: 'first_out_of_range', meta: { min: 1 } },
TEMPLATES,
FALLBACK,
)

expect(shown).toBe('graph: first must be between 1 and 200')
})

test('speaks the answer when the template asks for values and none arrived', () => {
const shown = errorText({ message: 'locale refused', code: 'locale_unknown' }, TEMPLATES, FALLBACK)

expect(shown).toBe('locale refused')
})

test('speaks the answer when a template asking for a decimal lacks its value', () => {
const shown = errorText(
{ message: 'the balance is short', code: 'short', meta: { held: 9 } },
{ short: 'You hold %(held).2f of the %(needed).2f asked for.' },
FALLBACK,
)

expect(shown).toBe('the balance is short')
})

test('fills a template asking for widths and decimals', () => {
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.' },
FALLBACK,
)

expect(shown).toBe('You hold 9.00 of the 12.5 asked for.')
})

test('speaks the answer when its code names something every object inherits', () => {
for (const code of ['constructor', 'toString', 'hasOwnProperty']) {
const shown = errorText({ message: 'the server said this', code }, TEMPLATES, FALLBACK)

expect(shown).toBe('the server said this')
}
})

test('speaks the answer when a template names a value every object inherits', () => {
const shown = errorText(
{ message: 'the server said this', code: 'inherited', meta: {} },
{ inherited: 'Value is %(constructor)s.' },
FALLBACK,
)

expect(shown).toBe('the server said this')
})

test('falls back to the words the caller supplied when the answer says nothing', () => {
const shown = errorText({ message: '' }, TEMPLATES, FALLBACK)

expect(shown).toBe(FALLBACK)
})

test('prefers a filled template over an answer that says nothing', () => {
const shown = errorText({ message: '', code: 'name_taken' }, TEMPLATES, FALLBACK)

expect(shown).toBe('That name is already taken.')
})
17 changes: 16 additions & 1 deletion test/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { __, getLocaleData } from '@wordpress/i18n'
import { expect, test } from 'vitest'

import { displayLocale, startLocale } from '../src/index.js'
import { displayLocale, rememberLocale, startLocale } from '../src/index.js'
import type { Catalog } from '../src/index.js'

const CATALOG: Catalog = {
Expand All @@ -23,6 +23,21 @@ test('remembers the settled locale for display', async () => {
expect(displayLocale()).toBe('es-ES')
})

test('holds the default locale from the moment the start is asked', async () => {
rememberLocale('de-DE')
let release: (locale: string) => void = () => {}
const pending = startLocale(
() => new Promise((resolve) => { release = resolve }),
[],
{ defaultLocale: 'es-ES' },
)

expect(displayLocale()).toBe('es-ES')

release('es-ES')
await pending
})

test('sets a loaded catalogue under its domain before returning', async () => {
await startLocale(async () => 'es-ES', [
{ domain: 'gottext-probe', load: async () => CATALOG },
Expand Down
Loading