diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bf4f63..2495a2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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'))" diff --git a/CHANGELOG.md b/CHANGELOG.md index a7a5b88..1fc39db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index f018bcb..f016979 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/catalog.ts b/src/catalog.ts index b4f5b5b..b66dbe8 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -6,6 +6,36 @@ export type Catalog = Record> /** 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 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 { + const byLocale = new Map Promise<{ default: Catalog }>>() + for (const [path, load] of Object.entries(chunks)) { + byLocale.set(stemOf(path), load) + } + return async (locale: string): Promise => { + 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. diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..aec42a2 --- /dev/null +++ b/src/errors.ts @@ -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 +} + +/** 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): 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, + 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) +} diff --git a/src/index.ts b/src/index.ts index fb54a38..1c5e519 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/start.ts b/src/start.ts index a513334..93ffd46 100644 --- a/src/start.ts +++ b/src/start.ts @@ -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 } @@ -31,6 +31,9 @@ export async function startLocale( entries: CatalogEntry[], options: LocaleOptions = {}, ): Promise { + if (options.defaultLocale !== undefined) { + rememberLocale(options.defaultLocale) + } const locale = await resolve() rememberLocale(locale) if (locale === options.defaultLocale) { diff --git a/src/testing.ts b/src/testing.ts new file mode 100644 index 0000000..307c8da --- /dev/null +++ b/src/testing.ts @@ -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) +} diff --git a/test/catalogs.test.ts b/test/catalogs.test.ts new file mode 100644 index 0000000..52b92cc --- /dev/null +++ b/test/catalogs.test.ts @@ -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() +}) diff --git a/test/errors.test.ts b/test/errors.test.ts new file mode 100644 index 0000000..f345a15 --- /dev/null +++ b/test/errors.test.ts @@ -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.') +}) diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 426f8a8..34b6231 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -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 = { @@ -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 }, diff --git a/test/testing.test.ts b/test/testing.test.ts new file mode 100644 index 0000000..604e05c --- /dev/null +++ b/test/testing.test.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { __, setLocaleData } from '@wordpress/i18n' +import { expect, test } from 'vitest' + +import { displayLocale, rememberLocale } from '../src/index.js' +import type { Catalog } from '../src/index.js' +import { resetLocale } from '../src/testing.js' + +const CATALOG: Catalog = { + '': { lang: 'es-ES', 'plural-forms': 'nplurals=2; plural=(n != 1);' }, + 'Older posts': ['Entradas anteriores'], +} + +test('takes every loaded domain back to its sources', () => { + setLocaleData(CATALOG, 'gottext-reset-one') + setLocaleData(CATALOG, 'gottext-reset-two') + setLocaleData(CATALOG) + + resetLocale() + + expect(__('Older posts', 'gottext-reset-one')).toBe('Older posts') + expect(__('Older posts', 'gottext-reset-two')).toBe('Older posts') + expect(__('Older posts')).toBe('Older posts') +}) + +test('settles back on the locale it is handed', () => { + rememberLocale('es-ES') + + resetLocale('fr-FR') + + expect(displayLocale()).toBe('fr-FR') +}) + +test('settles back on en-US when handed no locale', () => { + rememberLocale('es-ES') + + resetLocale() + + expect(displayLocale()).toBe('en-US') +})