From d746543a93c9f8267a9b1067288d4325e769dd1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Marie=20De=20Mey?= Date: Tue, 30 Apr 2024 21:24:14 +0300 Subject: [PATCH] client+translator done + reporting options --- README.md | 4 -- docs/README.md | 2 +- docs/client.md | 27 +++++++++---- docs/translator.md | 75 +++++++++++++++++++++++++++++++++++++ src/client/helpers.ts | 41 ++++++++++++++++---- src/client/index.ts | 9 ++++- src/client/interpolation.ts | 51 ++++++++++++------------- src/client/types.ts | 5 +++ 8 files changed, 164 insertions(+), 50 deletions(-) create mode 100644 docs/translator.md diff --git a/README.md b/README.md index ba744d2..1c85e34 100644 --- a/README.md +++ b/README.md @@ -258,10 +258,6 @@ import { reports, type TContext } from "omni18n"; client: I18nClient }*/ -reports.loading = ({ key, client }: TContext): string { - // report if not expected - return '...' -} reports.missing = ({ key, client }: TContext, fallback?: string): string { // report return fallback ?? `[${key}]` diff --git a/docs/README.md b/docs/README.md index f8b2655..da7b90a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ Projects using OmnI18n use it in 4 layers -1. [The `client`](./client.md): The client manages the cache and download along with text retrieval and interpolation +1. [The `client`](./client.md): The client manages the cache and download along with providing [`Translator`s](./translator.md) 2. (optional) The HTTP or any other layer. This part is implemented by the user 3. The `server`: The server exposes functions to interact with the languages 4. The `database`: A class implementing some interface that interacts directly with a database diff --git a/docs/client.md b/docs/client.md index d6dba82..183fb98 100644 --- a/docs/client.md +++ b/docs/client.md @@ -6,6 +6,8 @@ For instance, in the browser for an SPA is is instantiated once for the whole we ## Interactions and configurations +In order to acquire translations, the client just has to `enter` a zone to retrieve a [Translator](./translator.md) + ### With the server ```ts @@ -20,16 +22,15 @@ I18nClient(locales: OmnI18n.Locale[], condense: OmnI18n.Condense, onModification const client = new I18nClient(['fr', 'en'], server.condense, frontend.refreshTexts) ``` -### Global settings +### Reports -These are variables you can import and modify: +There are two ways to manage reports. There are also two report types : missing and error. The first one is for when a key is missing, the second one only happens when interpolating. -```ts -import { reports, formats, processors } from 'omni18n' -``` +Both return a string to display instead of the translated value. -#### `reports` +#### Global reporting +`reports` is a variable imported from `omni18n` who can (and should) be edited. It is called by the engine Reporting mechanism in case of problem. They both take an argument of type `TContext` describing mainly the client and the key where the problem occurred ```ts @@ -70,6 +71,16 @@ reports.error = ({ key, client }: TContext, error: string, spec: object) => { } ``` -#### `formats` +#### OO reporting + +The interface `ReportingClient` exposes the methods : +```ts +export interface ReportingClient extends OmnI18nClient { + missing(key: string, fallback: string | undefined, zones: OmnI18n.Zone[]): string + error(key: string, error: string, spec: object, zones: OmnI18n.Zone[]): string +} +``` + +Applications implementing this interface will have it called instead of the global `reports` value. -#### `processors` +> Of course, there will be no `super` \ No newline at end of file diff --git a/docs/translator.md b/docs/translator.md new file mode 100644 index 0000000..f71e330 --- /dev/null +++ b/docs/translator.md @@ -0,0 +1,75 @@ +# Translators + +`Translators` are the "magical object" that allows to dive into the dictionary. + +A translator represent a `TContext`, meaning an [`I18nClient`](./client.md), zones and a text-key (so, potentially a translated text) + +A translator `blup` can: +- `blup.subkey` or `blup['sub.key']`: Retrieve another translator for a sub-key +- `blup(1, 2, 3)`: Interpolate, optionally with arguments the translated key +- `"Here: " + blup`: Be interpreted as a string, in which case it will interpolate without arguments +- +> :warning: :hotsprings: `blup.then` returns an object that throws errors when accessed! +> If `blup.then` would return a translator, therefore a function, it would look like "thenable" and therefore make bugs when returned from a promise + +## Life of a translator + +> Shall no one take it personally + +A main translator is created with +```js +const T = await client.enter('zone1', 'zone2') +``` + +>The `await` part come from the fact the client might have to download some parts of the dictionary. It happens few, but it happens at least each time a page is rendered/a server is launched. + +This is a "root translator" and, therefore, its call takes a key as first argument: `T('my.key', ...)`, but is a regular translator in all other regards + +Other translators are simply "sub-translators" and need no awaiting. + +## Utility functions + +```js +function getContext(translator: Translator): TContext +``` + +Beside this `getContext`, some libraries expose a way to be dynamically translated by taking an object of translations. By example: + +```js +{ + tooLong: "The text is too long", + empty: "The text shouldn't be empty", + play: "Same player, try again" +} +``` + +There are two ways to produce such objects and make sure to produce texts exempt of `Translator` wizardry for the library to use. + +### Bulk from objects + +A `Translatable` is just an object whose leafs are string. The function `bulkObject` gives you a translated object from a keyed object. It allows you to define these objects format outside of the dynamism of a translator, then use it. + +```ts +const T = await client.enter('myLib') +myLibrary.messages = bulkObject(T, { + tooLong: 'err.thatLib.tooLong', + empty: 'err.thatLib.empty', + play: 'msg.thatLib.play', +}, ...args); +``` + +### Bulk from the dictionary + +Another way let that library' structure be described directly in the dictionary +> Don't forget that translators can read keys and write texts, only developers edit the keys + +Imagining the dictionary contains these keys: +- `groups.thatLib.tooLong` +- `groups.thatLib.empty` +- `groups.thatLib.play` + +We can now call +```ts +const T = await client.enter('myLib') +myLibrary.messages = bulkDictionary(T.groups.thatLib, ...args) +``` diff --git a/src/client/helpers.ts b/src/client/helpers.ts index 6ea0eab..3329879 100644 --- a/src/client/helpers.ts +++ b/src/client/helpers.ts @@ -8,7 +8,8 @@ import { zone, fallback, contextKey, - Translatable + Translatable, + ReportingClient } from './types' import { interpolate } from './interpolation' @@ -16,6 +17,20 @@ function entry(t: string, z: string, isFallback?: boolean): ClientDictionary { return { [text]: t, [zone]: z, ...(isFallback ? { [fallback]: true } : {}) } } +export function reportMissing(context: TContext, fallback?: string) { + const { client, key } = context + return 'missing' in client + ? (client).missing(key, fallback, context.zones) + : reports.missing(context, fallback) +} + +export function reportError(context: TContext, error: string, spec: object) { + const { client, key } = context + return 'error' in client + ? (client).error(key, error, spec, context.zones) + : reports.error(context, error, spec) +} + export const reports = { /** * Report a missing translation @@ -54,13 +69,14 @@ export function translate(context: TContext, args: any[]): string { } return value?.[2] - ? client.interpolate(context, reports.missing(context, value[0]), args) + ? client.interpolate(context, reportMissing(context, value[0]), args) : value ? client.interpolate(context, value[0], args) - : reports.missing(context) + : reportMissing(context) } export function translator(context: TContext): Translator { + Object.freeze(context) const translation = context.key ? function (...args: any[]): string { return translate(context, args) @@ -82,9 +98,18 @@ export function translator(context: TContext): Translator { case 'constructor': return String case 'then': // Must be unthenable in order to be awaited - return 'Translators must be unthenable. `then` cannot be used as a text key.' + return new Proxy( + {}, + { + get(target, p, receiver) { + const msg = 'Translators must be unthenable. `then` cannot be used as a text key.' + if (p === 'toString') return msg + throw new TranslationError(msg) + } + } + ) case contextKey: - return Object.freeze(context) + return context } if (typeof key !== 'string') throw new TranslationError(`Invalid key type: ${typeof key}`) return translator({ ...context, key: context.key ? `${context.key}.${key}` : key }) @@ -172,7 +197,7 @@ export function bulkObject( } return recursivelyTranslate(source) } -export function objectFromDictionary( +export function bulkDictionary( t: Translator, ...args: any[] ): T | string { @@ -184,12 +209,12 @@ export function objectFromDictionary( current = current[k] as ClientDictionary if (!current) break } - if (!current) return reports.missing({ ...context, key }) + if (!current) return reportMissing({ ...context, key }) function dictionaryToTranslation(obj: ClientDictionary, key: string): T | string { const rv: any = {} const subCtx = { ...context, key } const value = () => - interpolate(subCtx, obj[fallback] ? reports.missing(subCtx, obj[text]) : obj[text]!, args) + interpolate(subCtx, obj[fallback] ? reportMissing(subCtx, obj[text]) : obj[text]!, args) if (Object.keys(obj).every((k) => typeof k === 'symbol')) return value() for (const [k, v] of Object.entries(obj)) rv[k] = dictionaryToTranslation(v, key ? `${key}.${k}` : k) diff --git a/src/client/index.ts b/src/client/index.ts index d0936dc..2427278 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,9 @@ export { default as I18nClient, type TContext, getContext } from './client' -export { type ClientDictionary, TranslationError, type Translator } from './types' -export { reports, bulkObject, objectFromDictionary } from './helpers' +export { + TranslationError, + type ClientDictionary, + type Translator, + type ReportingClient +} from './types' +export { reports, bulkObject, bulkDictionary as objectFromDictionary } from './helpers' export { formats, processors } from './interpolation' diff --git a/src/client/interpolation.ts b/src/client/interpolation.ts index 1c13ad3..f5536fc 100644 --- a/src/client/interpolation.ts +++ b/src/client/interpolation.ts @@ -1,4 +1,4 @@ -import { reports, translate } from './helpers' +import { reportMissing, reportError, translate } from './helpers' import { TContext, TranslationError } from './types' export const formats: Record<'date' | 'number' | 'relative', Record> = { @@ -41,37 +41,35 @@ export const processors: Record string> = { }, ordinal(this: TContext, str: string) { const { client } = this - if (!client.internals.ordinals) return reports.missing({ ...this, key: 'internals.ordinals' }) + if (!client.internals.ordinals) return reportMissing({ ...this, key: 'internals.ordinals' }) const num = parseInt(str) - if (isNaN(num)) return reports.error(this, 'NaN', { str }) + if (isNaN(num)) return reportError(this, 'NaN', { str }) return client.internals.ordinals[client.ordinalRules.select(num)].replace('$', str) }, plural(this: TContext, str: string, designation: string, plural?: string) { const num = parseInt(str), { client } = this - if (isNaN(num)) return reports.error(this, 'NaN', { str }) + if (isNaN(num)) return reportError(this, 'NaN', { str }) const rule = client.cardinalRules.select(num) const rules: string | Record = plural ? { one: designation, other: plural } : designation if (typeof rules === 'string') { - if (!client.internals.plurals) return reports.missing({ ...this, key: 'internals.plurals' }) + if (!client.internals.plurals) return reportMissing({ ...this, key: 'internals.plurals' }) if (!client.internals.plurals[rule]) - return reports.error(this, 'Missing rule in plurals', { rule }) + return reportError(this, 'Missing rule in plurals', { rule }) return client.internals.plurals[rule].replace('$', designation) } - return rule in rules - ? rules[rule] - : reports.error(this, 'Rule not found', { rule, designation }) + return rule in rules ? rules[rule] : reportError(this, 'Rule not found', { rule, designation }) }, number(this: TContext, str: string, options?: any) { const num = parseFloat(str), { client } = this - if (isNaN(num)) return reports.error(this, 'NaN', { str }) + if (isNaN(num)) return reportError(this, 'NaN', { str }) if (typeof options === 'string') { if (!(options in formats.number)) - return reports.error(this, 'Invalid number options', { options }) + return reportError(this, 'Invalid number options', { options }) options = formats.number[options] } if (this.client.currency) @@ -85,10 +83,9 @@ export const processors: Record string> = { const nbr = parseInt(str), date = new Date(nbr), { client } = this - if (isNaN(nbr)) return reports.error(this, 'Invalid date', { str }) + if (isNaN(nbr)) return reportError(this, 'Invalid date', { str }) if (typeof options === 'string') { - if (!(options in formats.date)) - return reports.error(this, 'Invalid date options', { options }) + if (!(options in formats.date)) return reportError(this, 'Invalid date options', { options }) options = formats.date[options] } if (client.timeZone) @@ -101,17 +98,17 @@ export const processors: Record string> = { relative(this: TContext, str: string, options?: any) { const content = /(-?\d+)\s*(\w+)/.exec(str), { client } = this - if (!content) return reports.error(this, 'Invalid relative format', { str }) + if (!content) return reportError(this, 'Invalid relative format', { str }) const nbr = parseInt(content[1]), unit = content[2] const units = ['second', 'minute', 'hour', 'day', 'week', 'month', 'year'] units.push(...units.map((unit) => unit + 's')) - if (isNaN(nbr)) return reports.error(this, 'Invalid number', { str }) - if (!units.includes(unit)) return reports.error(this, 'Invalid unit', { unit }) + if (isNaN(nbr)) return reportError(this, 'Invalid number', { str }) + if (!units.includes(unit)) return reportError(this, 'Invalid unit', { unit }) if (typeof options === 'string') { if (!(options in formats.relative)) - return reports.error(this, 'Invalid date options', { options }) + return reportError(this, 'Invalid date options', { options }) options = formats.date[options] } return new Intl.RelativeTimeFormat(client.locales, options).format( @@ -122,25 +119,25 @@ export const processors: Record string> = { region(this: TContext, str: string) { return ( new Intl.DisplayNames(this.client.locales[0], { type: 'region' }).of(str) || - reports.error(this, 'Invalid region', { str }) + reportError(this, 'Invalid region', { str }) ) }, language(this: TContext, str: string) { return ( new Intl.DisplayNames(this.client.locales[0], { type: 'language' }).of(str) || - reports.error(this, 'Invalid language', { str }) + reportError(this, 'Invalid language', { str }) ) }, script(this: TContext, str: string) { return ( new Intl.DisplayNames(this.client.locales[0], { type: 'script' }).of(str) || - reports.error(this, 'Invalid script', { str }) + reportError(this, 'Invalid script', { str }) ) }, currency(this: TContext, str: string) { return ( new Intl.DisplayNames(this.client.locales[0], { type: 'currency' }).of(str) || - reports.error(this, 'Invalid currency', { str }) + reportError(this, 'Invalid currency', { str }) ) } } @@ -190,7 +187,7 @@ export function interpolate(context: TContext, text: string, args: any[]): strin ? val : dft !== undefined ? dft - : reports.error(context, 'Missing arg', { arg: i, key }) + : reportError(context, 'Missing arg', { arg: i, key }) } text = text.replace(/{{/g, '\u0001').replace(/}}/g, '\u0002') const placeholders = (text.match(/{(.*?)}/g) || []).map((placeholder) => { @@ -213,18 +210,18 @@ export function interpolate(context: TContext, text: string, args: any[]): strin .map((part) => objectArgument(part)) if (typeof proc === 'object') return params.length !== 1 || typeof params[0] !== 'string' - ? reports.error(context, 'Case needs a string case', { params }) + ? reportError(context, 'Case needs a string case', { params }) : params[0] in proc ? proc[params[0]] : 'default' in proc ? proc.default - : reports.error(context, 'Case not found', { case: params[0], cases: proc }) + : reportError(context, 'Case not found', { case: params[0], cases: proc }) if (proc.includes('.')) return translate({ ...context, key: proc }, params) - if (!(proc in processors)) return reports.error(context, 'Unknown processor', { proc }) + if (!(proc in processors)) return reportError(context, 'Unknown processor', { proc }) try { return processors[proc].call(context, ...params) } catch (error) { - return reports.error(context, 'Error in processor', { proc, error }) + return reportError(context, 'Error in processor', { proc, error }) } } }), diff --git a/src/client/types.ts b/src/client/types.ts index 408c196..aa7ef96 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -27,6 +27,11 @@ export interface OmnI18nClient { onModification?: OmnI18n.OnModification } +export interface ReportingClient extends OmnI18nClient { + missing(key: string, fallback: string | undefined, zones: OmnI18n.Zone[]): string + error(key: string, error: string, spec: object, zones: OmnI18n.Zone[]): string +} + export interface TContext { key: string zones: string[]