From 4bd85480c8a216d713ca427f5d00b56c9cc06671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Marie=20De=20Mey?= Date: Tue, 30 Apr 2024 19:18:13 +0300 Subject: [PATCH] bulk system --- docs/client.md | 7 --- src/client/client.ts | 41 ++++---------- src/client/helpers.ts | 104 +++++++++++++++++------------------- src/client/index.ts | 6 +-- src/client/interpolation.ts | 6 +-- src/client/types.ts | 10 +--- test/specifics.test.ts | 45 +++++++++------- 7 files changed, 94 insertions(+), 125 deletions(-) diff --git a/docs/client.md b/docs/client.md index 0548022..d6dba82 100644 --- a/docs/client.md +++ b/docs/client.md @@ -58,13 +58,6 @@ reports.missing = ({ key, client }: TContext, fallback?: string) => { } ``` -- A "missing key while loading" report - This one is called only when the client is in a loading state. If `onModification` was specified, it will be called once loaded. If not, the client will automatically check all the keys that went through this error to check them again. - -```ts -reports.loading = ({ client }: TContext) => '...' -``` - - An interpolation error When interpolating, an error calls this report with a textual description and some specifications depending on the error. diff --git a/src/client/client.ts b/src/client/client.ts index 8dfee8a..bc15167 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -1,4 +1,5 @@ /// + /** * i18n consumption/usage, both client and server side. */ @@ -6,15 +7,18 @@ import '../polyfill' import Defer from '../defer' import { ClientDictionary, + contextKey, OmnI18nClient, Internals, TContext as RootContext, text, zone, - fallback + fallback, + Translatable, + Translator } from './types' import { interpolate } from './interpolation' -import { longKeyList, parseInternals, recurExtend, reports, translator } from './helpers' +import { longKeyList, parseInternals, recurExtend, reports, translate, translator } from './helpers' export type TContext = RootContext @@ -27,8 +31,6 @@ export default class I18nClient implements OmnI18nClient { private toLoadZones = new Set() private loadDefer = new Defer() - public checkOnLoad = new Set() - public timeZone?: string public currency?: string @@ -49,10 +51,6 @@ export default class I18nClient implements OmnI18nClient { this.cardinalRules = new Intl.PluralRules(locales[0], { type: 'cardinal' }) } - get loading() { - return this.loadDefer.deferring - } - /** * This should be called for each user-control, page, ... * If zoning per user role, the call can specify no zone and the zone can be specified on main page-load or user change @@ -84,25 +82,6 @@ export default class I18nClient implements OmnI18nClient { this.internals = parseInternals(this.dictionary.internals) this.onModification?.(condensed.map(longKeyList).flat()) - for (const key of this.checkOnLoad) { - const keys = key.split('.') - let current = this.dictionary - let value = false, - fallenBack: string | undefined - for (const key of keys) { - if (!current[key]) break - if (current[key][text]) { - if (current[key][fallback]) fallenBack = current[key][text] - else { - value = true - break - } - } - current = current[key] - } - if (!value) reports.missing({ key, client: this, zones }, fallenBack) - } - this.checkOnLoad = new Set() } private async download(zones: string[]) { @@ -144,7 +123,9 @@ export default class I18nClient implements OmnI18nClient { this.onModification?.(Object.keys(entries)) } - interpolate(context: TContext, text: string, args: any[]): string { - return interpolate(context, text, args) - } + interpolate: (context: TContext, text: string, args: any[]) => string = interpolate +} + +export function getContext(translator: Translator): TContext { + return translator[contextKey] as TContext } diff --git a/src/client/helpers.ts b/src/client/helpers.ts index b81df89..6ea0eab 100644 --- a/src/client/helpers.ts +++ b/src/client/helpers.ts @@ -7,9 +7,8 @@ import { text, zone, fallback, - Translatable, - bulk, - BulkTranslator + contextKey, + Translatable } from './types' import { interpolate } from './interpolation' @@ -17,16 +16,7 @@ function entry(t: string, z: string, isFallback?: boolean): ClientDictionary { return { [text]: t, [zone]: z, ...(isFallback ? { [fallback]: true } : {}) } } -export function reportMissing(context: TContext, fallback?: string): string { - if (!context.client.loading) return reports.missing(context, fallback) - if (!context.client.onModification) context.client.checkOnLoad.add(context.key) - return reports.loading(context) -} - export const reports = { - loading({ client }: TContext): string { - return '...' // `onModification` callback has been provided - }, /** * Report a missing translation * @param key The key that is missing @@ -64,49 +54,10 @@ export function translate(context: TContext, args: any[]): string { } return value?.[2] - ? client.interpolate(context, reportMissing(context, value[0]), args) + ? client.interpolate(context, reports.missing(context, value[0]), args) : value ? client.interpolate(context, value[0], args) - : reportMissing(context) -} - -function translateBulk( - context: TContext -): BulkTranslator { - return function (source: T | string = '', ...args: any[]): T | string { - const { key, client } = context, - keyPfx = key ? key + '.' : '' - function recursivelyTranslate(obj: T): T { - return Object.fromEntries( - Object.entries(obj).map(([k, v]) => [ - k, - typeof v === 'string' - ? translate({ ...context, key: keyPfx + v }, args) - : recursivelyTranslate(v) - ]) - ) as T - } - if (typeof source === 'object') return recursivelyTranslate(source) - let current = client.dictionary - const finalKey = !key ? source : !source ? key : `${key}.${source}` - for (const k of finalKey.split('.')) { - current = current[k] as ClientDictionary - if (!current) break - } - if (!current) return reportMissing(context) - function dictionaryToTranslation(obj: ClientDictionary, key: string): T | string { - const rv: any = {} - const subCtx = { ...context, key } - const value = () => - 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) - if (obj[text]) Object.defineProperty(rv, 'toString', { value }) - return rv - } - return dictionaryToTranslation(current, finalKey) - } + : reports.missing(context) } export function translator(context: TContext): Translator { @@ -132,8 +83,8 @@ export function translator(context: TContext): Translator { 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.' - case bulk: - return translateBulk(context) + case contextKey: + return Object.freeze(context) } if (typeof key !== 'string') throw new TranslationError(`Invalid key type: ${typeof key}`) return translator({ ...context, key: context.key ? `${context.key}.${key}` : key }) @@ -204,3 +155,46 @@ export function longKeyList(condensed: OmnI18n.CondensedDictionary) { recur(condensed, '') return keys } + +export function bulkObject( + t: Translator, + source: T, + ...args: any[] +): T { + const context = t[contextKey] + function recursivelyTranslate(obj: T): T { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [ + k, + typeof v === 'string' ? translate({ ...context, key: v }, args) : recursivelyTranslate(v) + ]) + ) as T + } + return recursivelyTranslate(source) +} +export function objectFromDictionary( + t: Translator, + ...args: any[] +): T | string { + const context = t[contextKey], + { client, key } = context + + let current = client.dictionary + for (const k of key.split('.')) { + current = current[k] as ClientDictionary + if (!current) break + } + if (!current) return reports.missing({ ...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) + 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) + if (obj[text]) Object.defineProperty(rv, 'toString', { value }) + return rv + } + return dictionaryToTranslation(current, key) +} diff --git a/src/client/index.ts b/src/client/index.ts index 137f94d..d0936dc 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,4 @@ -export { default as I18nClient, type TContext } from './client' -export { bulk, type ClientDictionary, TranslationError, type Translator } from './types' -export { reports } from './helpers' +export { default as I18nClient, type TContext, getContext } from './client' +export { type ClientDictionary, TranslationError, type Translator } from './types' +export { reports, bulkObject, objectFromDictionary } from './helpers' export { formats, processors } from './interpolation' diff --git a/src/client/interpolation.ts b/src/client/interpolation.ts index e363530..1c13ad3 100644 --- a/src/client/interpolation.ts +++ b/src/client/interpolation.ts @@ -1,4 +1,4 @@ -import { reportMissing, reports, translate } from './helpers' +import { reports, translate } from './helpers' import { TContext, TranslationError } from './types' export const formats: Record<'date' | 'number' | 'relative', Record> = { @@ -41,7 +41,7 @@ export const processors: Record string> = { }, ordinal(this: TContext, str: string) { const { client } = this - if (!client.internals.ordinals) return reportMissing({ ...this, key: 'internals.ordinals' }) + if (!client.internals.ordinals) return reports.missing({ ...this, key: 'internals.ordinals' }) const num = parseInt(str) if (isNaN(num)) return reports.error(this, 'NaN', { str }) return client.internals.ordinals[client.ordinalRules.select(num)].replace('$', str) @@ -56,7 +56,7 @@ export const processors: Record string> = { : designation if (typeof rules === 'string') { - if (!client.internals.plurals) return reportMissing({ ...this, key: 'internals.plurals' }) + if (!client.internals.plurals) return reports.missing({ ...this, key: 'internals.plurals' }) if (!client.internals.plurals[rule]) return reports.error(this, 'Missing rule in plurals', { rule }) return client.internals.plurals[rule].replace('$', designation) diff --git a/src/client/types.ts b/src/client/types.ts index edeb8d8..408c196 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -6,7 +6,7 @@ export interface Internals { export const zone = Symbol('Zone'), text = Symbol('Text'), fallback = Symbol('Fallback'), - bulk = Symbol('Bulk') + contextKey = Symbol('context') export type ClientDictionary = { [key: string]: ClientDictionary @@ -24,8 +24,6 @@ export interface OmnI18nClient { timeZone?: string currency?: string interpolate(context: TContext, text: string, args: any[]): string - readonly loading: boolean - readonly checkOnLoad: Set onModification?: OmnI18n.OnModification } @@ -39,13 +37,9 @@ export class TranslationError extends Error { name = 'TranslationError' } -export type BulkTranslator = - | ((source?: string, ...args: any[]) => T | string) - | ((source: T, ...args: any[]) => T) - export type Translator = ((...args: any[]) => string) & { [k: string]: Translator - [bulk](source: T | string, ...args: any[]): T | string + [contextKey]: TContext } export type Translatable = { [key: string]: Translatable | string } diff --git a/test/specifics.test.ts b/test/specifics.test.ts index 38be2a3..cb8621a 100644 --- a/test/specifics.test.ts +++ b/test/specifics.test.ts @@ -1,10 +1,12 @@ import { FileDB, + I18nClient, InteractiveServer, MemDictionary, TContext, Translator, - bulk, + bulkObject, + objectFromDictionary, reports } from '../src/index' import { readFile, writeFile, unlink } from 'node:fs/promises' @@ -17,43 +19,48 @@ reports.missing = ({ key }: TContext, fallback?: string) => { } describe('bulk', () => { - let T: Translator + let T: Translator, client: I18nClient const expected = { - ok: 'fr-v1', + ok: 'fr-v1.42', missing: 'en-v2', sub: { v3: 'fr-v3' } } beforeAll(async () => { - const { Tp } = localStack({ - 'sub.obj.v1': { fr: 'fr-v1' }, - 'sub.obj.v2': { en: 'en-v2' }, - 'sub.obj.v3': { fr: 'fr-v3' }, - 'struct.obj.ok': { fr: 'fr-v1' }, - 'struct.obj.missing': { en: 'en-v2' }, - 'struct.obj.sub.v3': { fr: 'fr-v3' }, - 'struct.obj.sub': { fr: 'toString' } + const { Tp, client: lclClient } = localStack({ + 'obj.v1': { fr: 'fr-v1.{=parm}' }, + 'obj.v2': { en: 'en-v2' }, + 'obj.v3': { fr: 'fr-v3' }, + 'struct.ok': { fr: 'fr-v1.{=parm}' }, + 'struct.missing': { en: 'en-v2' }, + 'struct.sub.v3': { fr: 'fr-v3' }, + 'struct.sub': { fr: 'toString' } }) T = await Tp + client = lclClient }) test('from object', async () => { misses.mockClear() expect( - T.sub[bulk]({ - ok: 'obj.v1', - missing: 'obj.v2', - sub: { v3: 'obj.v3' } - }) + bulkObject( + T, + { + ok: 'obj.v1', + missing: 'obj.v2', + sub: { v3: 'obj.v3' } + }, + { parm: 42 } + ) ).toEqual(expected) - expect(misses).toHaveBeenCalledWith('sub.obj.v2') + expect(misses).toHaveBeenCalledWith('obj.v2') }) test('from dictionary', async () => { misses.mockClear() - const built = T.struct[bulk]('obj') + const built = objectFromDictionary(T.struct, { parm: 42 }) expect(built).toEqual(expected) - expect(misses).toHaveBeenCalledWith('struct.obj.missing') + expect(misses).toHaveBeenCalledWith('struct.missing') expect('' + built.sub).toBe('toString') }) })