intl-schematic
Advanced tools
Comparing version 0.0.3 to 1.0.0-rc.0
@@ -1,38 +0,57 @@ | ||
import { callPlugins } from './plugins/core'; | ||
export * from './ts.schema.d'; | ||
/** | ||
* Creates a translation function (commonly known as `t()` or `$t()`) | ||
* | ||
* @param getLocaleDocument should return a translation document | ||
* @param currentLocaleId should return a current Intl.Locale | ||
* @param options | ||
* @returns a tranlation function that accepts a key to look up in the translation document | ||
*/ | ||
export const createTranslator = (getLocaleDocument, currentLocaleId, options = {}) => { | ||
const { processors = {}, plugins = [], } = options; | ||
const translate = function (key, input, parameter) { | ||
export function createTranslator(getLocaleDocument, plugins) { | ||
return (function translate(key, ...args) { | ||
const doc = getLocaleDocument(); | ||
const callHook = (hook, value, _input = input) => callPluginsHook(hook, value, _input, parameter, currentLocaleId, key, doc) ?? key; | ||
if (!doc) { | ||
return callHook('docNotFound'); | ||
const contextPlugins = this.plugins ?? plugins ?? []; | ||
for (const [index, plugin] of contextPlugins.entries()) | ||
if (plugin.match(doc[key], key, doc)) { | ||
const pluginContext = createPluginContext.call(this, plugin, index); | ||
try { | ||
const pluginResult = plugin.translate.call(pluginContext, ...args); | ||
if (typeof pluginResult === 'string') { | ||
return pluginResult; | ||
} | ||
} | ||
catch { } | ||
} | ||
const plainKey = doc[key]; | ||
return typeof plainKey === 'string' ? plainKey : key; | ||
function createPluginContext(plugin, index) { | ||
const contextualPlugins = contextPlugins.reduce((obj, pl) => ({ | ||
...obj, | ||
[pl.name]: createPluginInterface(pl), | ||
}), {}); | ||
const createdContext = { | ||
name: plugin.name, | ||
originalCallArgs: args, | ||
originalKey: key, | ||
originalValue: doc[key], | ||
...this.pluginContext, | ||
plugins: contextualPlugins, | ||
doc, | ||
key, | ||
value: doc[key], | ||
translate: translateFromContext, | ||
}; | ||
return createdContext; | ||
function translateFromContext(subkey, ...args) { | ||
return translate.call({ | ||
plugins: subkey !== key | ||
? contextPlugins | ||
: contextPlugins?.slice(index), | ||
pluginContext: createdContext, | ||
}, subkey, ...args); | ||
} | ||
function createPluginInterface(pt) { | ||
return { | ||
translate: (subkey, ...args) => (pt.translate.call({ | ||
...createdContext, | ||
key: subkey, | ||
value: doc[subkey] | ||
}, ...args)), | ||
match: pt.match, | ||
info: pt.info, | ||
}; | ||
} | ||
} | ||
const currentKey = doc[key]; | ||
if (currentKey == null) { | ||
return callHook('keyNotFound', key); | ||
} | ||
// Process a plain-string | ||
if (typeof currentKey === 'string') { | ||
return callHook('keyProcessed', currentKey); | ||
} | ||
// Process a function record | ||
// TODO: move into a plugin | ||
if (typeof currentKey === 'function') { | ||
return callHook('keyProcessed', currentKey(...(Array.isArray(input) ? input : []))); | ||
} | ||
return callHook('keyFound', currentKey); | ||
}; | ||
const callPluginsHook = callPlugins(translate, plugins); | ||
// Initialize plugins | ||
callPluginsHook('initPlugin', processors, undefined, undefined, currentLocaleId, '', undefined, undefined); | ||
return translate; | ||
}; | ||
}).bind({ plugins }); | ||
} |
@@ -1,43 +0,1 @@ | ||
export const callPlugins = (translate, plugins = []) => { | ||
const pluginsPerHook = plugins.reduce((obj, plugin) => { | ||
for (const _hookName in plugin) | ||
if (typeof plugin[_hookName] === 'function') { | ||
const hookName = _hookName; | ||
const hook = plugin[hookName]; | ||
if (hookName in obj) { | ||
obj[hookName].push(hook); | ||
} | ||
else { | ||
obj[hookName] = [hook]; | ||
} | ||
} | ||
return obj; | ||
}, {}); | ||
const callPluginsForHook = (hook, ...[value, input, parameter, currentLocaleId, key, doc, initiatorPlugin]) => { | ||
if (!pluginsPerHook[hook]) { | ||
return value == null ? undefined : String(value); | ||
} | ||
let val = value; | ||
for (const pluginHook of pluginsPerHook[hook]) { | ||
const pluginResult = pluginHook.call({ | ||
callHook(_hook, value) { | ||
if (hook === _hook) { | ||
// Prevent recursion | ||
return; | ||
} | ||
return callPluginsForHook(_hook, value, input, parameter, currentLocaleId, key, doc, pluginHook.name); | ||
}, | ||
translate | ||
}, val, input, parameter, currentLocaleId, key, doc, initiatorPlugin); | ||
if (typeof pluginResult === 'string') { | ||
return pluginResult; | ||
} | ||
if (pluginResult != null) { | ||
val = pluginResult; | ||
} | ||
} | ||
return val == null ? undefined : String(val); | ||
}; | ||
return callPluginsForHook; | ||
}; | ||
export const createPlugin = (plugin) => plugin; | ||
export const createPlugin = (name, match, options) => ({ name, match, translate: options.translate ?? (() => undefined), info: options.info }); |
@@ -1,10 +0,8 @@ | ||
import { ArrayRecordPlugin } from './array-record'; | ||
import { ObjectRecordPlugin } from './object-record'; | ||
import { ProcessorPlugin } from './processed-record'; | ||
import { ResolveMissingKeyPlugin } from './resolve-missing'; | ||
export const defaultPlugins = [ | ||
ProcessorPlugin, | ||
ArrayRecordPlugin, | ||
ObjectRecordPlugin, | ||
ResolveMissingKeyPlugin, | ||
import { LocaleProviderPlugin } from './locale'; | ||
import { ArraysPlugin } from './arrays'; | ||
import { ProcessorsPlugin } from './processors/plugin'; | ||
export const defaultPlugins = (currentLocale, processors) => [ | ||
LocaleProviderPlugin(currentLocale), | ||
ArraysPlugin, | ||
ProcessorsPlugin(processors), | ||
]; |
@@ -1,38 +0,57 @@ | ||
import { callPlugins } from './plugins/core'; | ||
export * from './ts.schema.d'; | ||
/** | ||
* Creates a translation function (commonly known as `t()` or `$t()`) | ||
* | ||
* @param getLocaleDocument should return a translation document | ||
* @param currentLocaleId should return a current Intl.Locale | ||
* @param options | ||
* @returns a tranlation function that accepts a key to look up in the translation document | ||
*/ | ||
export const createTranslator = (getLocaleDocument, currentLocaleId, options = {}) => { | ||
const { processors = {}, plugins = [], } = options; | ||
const translate = function (key, input, parameter) { | ||
export function createTranslator(getLocaleDocument, plugins) { | ||
return (function translate(key, ...args) { | ||
const doc = getLocaleDocument(); | ||
const callHook = (hook, value, _input = input) => callPluginsHook(hook, value, _input, parameter, currentLocaleId, key, doc) ?? key; | ||
if (!doc) { | ||
return callHook('docNotFound'); | ||
const contextPlugins = this.plugins ?? plugins ?? []; | ||
for (const [index, plugin] of contextPlugins.entries()) | ||
if (plugin.match(doc[key], key, doc)) { | ||
const pluginContext = createPluginContext.call(this, plugin, index); | ||
try { | ||
const pluginResult = plugin.translate.call(pluginContext, ...args); | ||
if (typeof pluginResult === 'string') { | ||
return pluginResult; | ||
} | ||
} | ||
catch { } | ||
} | ||
const plainKey = doc[key]; | ||
return typeof plainKey === 'string' ? plainKey : key; | ||
function createPluginContext(plugin, index) { | ||
const contextualPlugins = contextPlugins.reduce((obj, pl) => ({ | ||
...obj, | ||
[pl.name]: createPluginInterface(pl), | ||
}), {}); | ||
const createdContext = { | ||
name: plugin.name, | ||
originalCallArgs: args, | ||
originalKey: key, | ||
originalValue: doc[key], | ||
...this.pluginContext, | ||
plugins: contextualPlugins, | ||
doc, | ||
key, | ||
value: doc[key], | ||
translate: translateFromContext, | ||
}; | ||
return createdContext; | ||
function translateFromContext(subkey, ...args) { | ||
return translate.call({ | ||
plugins: subkey !== key | ||
? contextPlugins | ||
: contextPlugins?.slice(index), | ||
pluginContext: createdContext, | ||
}, subkey, ...args); | ||
} | ||
function createPluginInterface(pt) { | ||
return { | ||
translate: (subkey, ...args) => (pt.translate.call({ | ||
...createdContext, | ||
key: subkey, | ||
value: doc[subkey] | ||
}, ...args)), | ||
match: pt.match, | ||
info: pt.info, | ||
}; | ||
} | ||
} | ||
const currentKey = doc[key]; | ||
if (currentKey == null) { | ||
return callHook('keyNotFound', key); | ||
} | ||
// Process a plain-string | ||
if (typeof currentKey === 'string') { | ||
return callHook('keyProcessed', currentKey); | ||
} | ||
// Process a function record | ||
// TODO: move into a plugin | ||
if (typeof currentKey === 'function') { | ||
return callHook('keyProcessed', currentKey(...(Array.isArray(input) ? input : []))); | ||
} | ||
return callHook('keyFound', currentKey); | ||
}; | ||
const callPluginsHook = callPlugins(translate, plugins); | ||
// Initialize plugins | ||
callPluginsHook('initPlugin', processors, undefined, undefined, currentLocaleId, '', undefined, undefined); | ||
return translate; | ||
}; | ||
}).bind({ plugins }); | ||
} |
@@ -1,43 +0,1 @@ | ||
export const callPlugins = (translate, plugins = []) => { | ||
const pluginsPerHook = plugins.reduce((obj, plugin) => { | ||
for (const _hookName in plugin) | ||
if (typeof plugin[_hookName] === 'function') { | ||
const hookName = _hookName; | ||
const hook = plugin[hookName]; | ||
if (hookName in obj) { | ||
obj[hookName].push(hook); | ||
} | ||
else { | ||
obj[hookName] = [hook]; | ||
} | ||
} | ||
return obj; | ||
}, {}); | ||
const callPluginsForHook = (hook, ...[value, input, parameter, currentLocaleId, key, doc, initiatorPlugin]) => { | ||
if (!pluginsPerHook[hook]) { | ||
return value == null ? undefined : String(value); | ||
} | ||
let val = value; | ||
for (const pluginHook of pluginsPerHook[hook]) { | ||
const pluginResult = pluginHook.call({ | ||
callHook(_hook, value) { | ||
if (hook === _hook) { | ||
// Prevent recursion | ||
return; | ||
} | ||
return callPluginsForHook(_hook, value, input, parameter, currentLocaleId, key, doc, pluginHook.name); | ||
}, | ||
translate | ||
}, val, input, parameter, currentLocaleId, key, doc, initiatorPlugin); | ||
if (typeof pluginResult === 'string') { | ||
return pluginResult; | ||
} | ||
if (pluginResult != null) { | ||
val = pluginResult; | ||
} | ||
} | ||
return val == null ? undefined : String(val); | ||
}; | ||
return callPluginsForHook; | ||
}; | ||
export const createPlugin = (plugin) => plugin; | ||
export const createPlugin = (name, match, options) => ({ name, match, translate: options.translate ?? (() => undefined), info: options.info }); |
@@ -1,10 +0,8 @@ | ||
import { ArrayRecordPlugin } from './array-record'; | ||
import { ObjectRecordPlugin } from './object-record'; | ||
import { ProcessorPlugin } from './processed-record'; | ||
import { ResolveMissingKeyPlugin } from './resolve-missing'; | ||
export const defaultPlugins = [ | ||
ProcessorPlugin, | ||
ArrayRecordPlugin, | ||
ObjectRecordPlugin, | ||
ResolveMissingKeyPlugin, | ||
import { LocaleProviderPlugin } from './locale'; | ||
import { ArraysPlugin } from './arrays'; | ||
import { ProcessorsPlugin } from './processors/plugin'; | ||
export const defaultPlugins = (currentLocale, processors) => [ | ||
LocaleProviderPlugin(currentLocale), | ||
ArraysPlugin, | ||
ProcessorsPlugin(processors), | ||
]; |
{ | ||
"name": "intl-schematic", | ||
"version": "0.0.3", | ||
"version": "1.0.0-rc.0", | ||
"license": "MIT", | ||
@@ -11,6 +11,9 @@ "repository": { | ||
".": "./src/index", | ||
"./processors": "./src/processors/index", | ||
"./plugins/processors": "./src/plugins/processors/plugin", | ||
"./processors": "./src/plugins/processors/index", | ||
"./processors/*": "./src/plugins/processors/*", | ||
"./plugins": "./src/plugins/index", | ||
"./plugins/*": "./src/plugins/*", | ||
"./translation.schema": "./src/translation.schema", | ||
"./ts.schema": "./src/ts.schema" | ||
"./schema": "./src/ts.schema" | ||
}, | ||
@@ -23,9 +26,21 @@ "typesVersions": { | ||
"processors": [ | ||
"./src/processors/index.ts" | ||
"./src/plugins/processors/index.ts" | ||
], | ||
"plugins/processors": [ | ||
"./src/plugins/processors/plugin.ts" | ||
], | ||
"processors/*": [ | ||
"./src/plugins/processors/*" | ||
], | ||
"plugins": [ | ||
"./src/plugins/index.ts" | ||
], | ||
"plugins/*": [ | ||
"./src/plugins/*" | ||
], | ||
"translation.schema": [ | ||
"./src/translation.schema.d.ts" | ||
], | ||
"schema": [ | ||
"./src/ts.schema.d.ts" | ||
] | ||
@@ -32,0 +47,0 @@ } |
@@ -1,34 +0,15 @@ | ||
# intl-schematic (WIP) | ||
<h1 align="center"> | ||
<picture> | ||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Raiondesu/intl-schematic/main/logo/Dark%20Logo.svg"> | ||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/Raiondesu/intl-schematic/main/logo/Light%20Logo.svg"> | ||
<img alt="intl-schematic" src="https://raw.githubusercontent.com/Raiondesu/intl-schematic/main/logo/Light%20Logo.svg"> | ||
</picture> | ||
</h1> | ||
<p align="center"> | ||
A tiny library (3kb, zero-dependency) that allows to localize and format strings while sparingly using the browser-standard [`Intl` API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl). | ||
Key features include: | ||
- **Full type-safety**: full autocomplete on translation keys, typed translation parameters and more; | ||
- **Tree-shakable**: only take what you need; | ||
- **Pluginable**: extend any processing step without limits; | ||
- **JSON-validation using a JSON-schema**: intellisense and popup hints right in the translation document; | ||
- **Dynamic strings with custom pre-processors**: write custom translation logic right in JSON; | ||
- **Reference translation keys inside of other translation keys**: all with JSON-compatible syntax; | ||
- **No string-interpolation**: translation strings will never be processed or mangled by-default, so all unicode symbols are safe to use; | ||
- **Basic localized formatters**: declare formatting rules and translations in the same place. | ||
</p> | ||
## Why | ||
I've grown frustrated with current implementations of popular l10n/i18n libraries, many of which: | ||
- lack runtime JSON support, | ||
- rely on custom-written localization logic (a lot of which is already implemented in `Intl`), | ||
- are over-tailored to specific frameworks or SaaS solutions, | ||
- lack support for modular translation documents or asynchronous/real-time localization, | ||
- interpolate over translated strings - resulting in overreliance on custom string template syntax - different for each library, | ||
- force a certain architecture on a project. | ||
This library will try to avoid these common pitfalls, while retaining a small size and good performance. | ||
## No-goals | ||
This library will **not** support: | ||
- **Translation key nesting**: needlessly complicates key lookup and maintenance, use namespaced keys instead; | ||
- **String interpolation**: while custom processors can do anything with the translated string, the library by-itself does not and will not do any processing on the strings. | ||
## Usage | ||
@@ -44,13 +25,10 @@ | ||
const en = { | ||
"hello": "Hello, World!", | ||
"hello-name": name => `Hello, ${name}!` | ||
"hello": "Hello, World!" | ||
}; | ||
``` | ||
### Define functions that return a translation document and a locale | ||
### Define a function that return a translation document | ||
```js | ||
const getDocument = () => en; | ||
const getLocale = () => new Intl.Locale('en') | ||
``` | ||
@@ -63,3 +41,3 @@ | ||
const t = createTranslator(getDocument, getLocale); | ||
const t = createTranslator(getDocument); | ||
``` | ||
@@ -71,3 +49,2 @@ | ||
console.log(t('hello')); // `Hello, World!` | ||
console.log(t('hello-name', ['Bob'])); // `Hello, Bob!` | ||
``` | ||
@@ -78,4 +55,5 @@ | ||
These allow to use standard [Intl](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) features, | ||
like [`DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) | ||
or [`PluralRules`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules). | ||
like [`DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat), | ||
[`PluralRules`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules) | ||
and [`DisplayNames`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames). | ||
@@ -100,8 +78,10 @@ ```js | ||
const t = createTranslator(getDocument, getLocale, { | ||
plugins: defaultPlugins, | ||
processors: defaultProcessors, | ||
}); | ||
const getLocale = () => new Intl.Locale('en'); | ||
const t = createTranslator(getDocument, defaultPlugins( | ||
getLocale | ||
defaultProcessors | ||
)); | ||
console.log(t('price', 123)); // "US$123" | ||
``` |
207
src/index.ts
@@ -1,12 +0,7 @@ | ||
import type { LocaleInputParameter, LocaleKey, LocaleOptionsParameter, Translation, TranslationProxy } from './ts.schema'; | ||
import type { Processors, defaultProcessors } from './processors'; | ||
import { callPlugins } from './plugins/core'; | ||
import type { Plugin } from './plugins/core'; | ||
import type { LocaleKey } from './ts.schema'; | ||
import type { PluginContext, PluginInterface, Plugin, PluginRegistry } from './plugins/core'; | ||
export * from './ts.schema.d'; | ||
// TODO: decouple processor architecture from plugins | ||
interface Options<P extends Processors, Locale extends Translation> { | ||
processors?: P; | ||
plugins?: Plugin<Locale, P>[]; | ||
interface TranslationContext { | ||
plugins?: readonly Plugin[]; | ||
pluginContext?: PluginContext; | ||
} | ||
@@ -18,71 +13,157 @@ | ||
* @param getLocaleDocument should return a translation document | ||
* @param currentLocaleId should return a current Intl.Locale | ||
* @param options | ||
* @returns a tranlation function that accepts a key to look up in the translation document | ||
*/ | ||
export const createTranslator: { | ||
<Locale extends Translation>( | ||
getLocaleDocument: () => Locale | undefined, | ||
currentLocaleId: () => Intl.Locale | undefined, | ||
options?: Omit<Options<typeof defaultProcessors, Locale>, 'processors'>, | ||
): TranslationProxy<Locale, typeof defaultProcessors>; | ||
<Locale extends Translation, P extends Processors>( | ||
getLocaleDocument: () => Locale | undefined, | ||
currentLocaleId: () => Intl.Locale | undefined, | ||
options?: Options<P, Locale> | ||
): TranslationProxy<Locale, P>; | ||
} = <Locale extends Translation>( | ||
export function createTranslator<Locale extends Record<string, string>>( | ||
getLocaleDocument: () => Locale | undefined, | ||
currentLocaleId: () => Intl.Locale | undefined, | ||
options: Options<Processors, Locale> = {}, | ||
) => { | ||
const { | ||
processors = {} as Processors, | ||
plugins = [], | ||
} = options; | ||
): SimpleTranslationFunction<Locale>; | ||
const translate = function <K extends LocaleKey<Locale>>( | ||
key: K, | ||
input?: LocaleInputParameter<Locale, LocaleKey<Locale>, Processors>, | ||
parameter?: LocaleOptionsParameter<Locale, LocaleKey<Locale>, Processors> | ||
): string { | ||
/** | ||
* Creates a translation function (commonly known as `t()` or `$t()`) | ||
* | ||
* @param getLocaleDocument should return a translation document | ||
* @param plugins an array of plugins, each will be applied to the translation key in their respective order | ||
* @returns a tranlation function that accepts a key to look up in the translation document | ||
*/ | ||
export function createTranslator< | ||
const P extends readonly Plugin[], | ||
LocaleDoc extends Record<string, PMatch<P> | string>, | ||
>( | ||
getLocaleDocument: () => LocaleDoc, | ||
plugins: P, | ||
): TranslationFunction<LocaleDoc, P>; | ||
export function createTranslator< | ||
const P extends readonly Plugin[], | ||
LocaleDoc extends Record<string, PMatch<P> | string>, | ||
>( | ||
getLocaleDocument: () => LocaleDoc, | ||
plugins?: P, | ||
): any { | ||
return (function translate(this: TranslationContext, key: string, ...args: unknown[]) { | ||
const doc = getLocaleDocument(); | ||
const callHook = ( | ||
hook: keyof Omit<Plugin<Locale, Processors>, 'name'>, | ||
value?: unknown, | ||
_input: typeof input = input, | ||
) => callPluginsHook(hook, value, _input, parameter, currentLocaleId, key, doc) ?? key; | ||
const contextPlugins = this.plugins ?? plugins ?? []; | ||
if (!doc) { | ||
return callHook('docNotFound'); | ||
} | ||
for (const [index, plugin] of contextPlugins.entries()) | ||
if (plugin.match(doc[key], key, doc)) { | ||
const pluginContext: PluginContext = createPluginContext.call(this, plugin, index); | ||
const currentKey = doc[key]; | ||
// Do not break if a plugin stops working | ||
try { | ||
const pluginResult = plugin.translate.call(pluginContext, ...args); | ||
if (currentKey == null) { | ||
return callHook('keyNotFound', key); | ||
} | ||
if (typeof pluginResult === 'string') { | ||
return pluginResult; | ||
} | ||
} catch {} | ||
} | ||
// Process a plain-string | ||
if (typeof currentKey === 'string') { | ||
return callHook('keyProcessed', currentKey); | ||
} | ||
const plainKey = doc[key]; | ||
// Process a function record | ||
// TODO: move into a plugin | ||
if (typeof currentKey === 'function') { | ||
return callHook('keyProcessed', currentKey(...(Array.isArray(input) ? input : []))); | ||
return typeof plainKey === 'string' ? plainKey : key; | ||
function createPluginContext( | ||
this: TranslationContext, | ||
plugin: Plugin, | ||
index: number | ||
): PluginContext { | ||
const contextualPlugins = contextPlugins.reduce<PluginContext['plugins']>((obj, pl) => ({ | ||
...obj, | ||
[pl.name]: createPluginInterface(pl), | ||
}), {}); | ||
const createdContext: PluginContext = { | ||
name: plugin.name, | ||
originalCallArgs: args, | ||
originalKey: key, | ||
originalValue: doc[key], | ||
...this.pluginContext, | ||
plugins: contextualPlugins, | ||
doc, | ||
key, | ||
value: doc[key], | ||
translate: translateFromContext, | ||
}; | ||
return createdContext; | ||
function translateFromContext(subkey: string, ...args: unknown[]) { | ||
return translate.call({ | ||
plugins: subkey !== key | ||
? contextPlugins | ||
: contextPlugins?.slice(index), | ||
pluginContext: createdContext, | ||
}, subkey, ...args) | ||
} | ||
function createPluginInterface(pt: Plugin): PluginInterface | undefined { | ||
return { | ||
translate: (subkey, ...args) => ( | ||
pt.translate.call({ | ||
...createdContext, | ||
key: subkey, | ||
value: doc[subkey] | ||
}, ...args) | ||
), | ||
match: pt.match, | ||
info: pt.info, | ||
}; | ||
} | ||
} | ||
}).bind({ plugins }); | ||
} | ||
return callHook('keyFound', currentKey); | ||
} as TranslationProxy<Locale, Processors>; | ||
export type PMatch<P extends readonly Plugin[]> = ( | ||
[] extends P ? never : P extends readonly Plugin<infer Match, any>[] ? Match : never | ||
); | ||
const callPluginsHook = callPlugins(translate, plugins); | ||
type NamePerPlugin<P extends readonly Plugin[]> = { | ||
[key in keyof P]: P[key] extends Plugin<any, any, infer Name> ? Name : never; | ||
}; | ||
// Initialize plugins | ||
callPluginsHook('initPlugin', processors, undefined, undefined, currentLocaleId, '', undefined, undefined); | ||
type MatchPerPlugin<P extends readonly Plugin[], Names extends NamePerPlugin<P> = NamePerPlugin<P>> = { | ||
[key in keyof Names & keyof P & `${number}` as Names[key]]: P[key] extends Plugin<infer Match, any> ? Match : never; | ||
}; | ||
return translate; | ||
type InfoPerPlugin<P extends readonly Plugin[], Names extends NamePerPlugin<P> = NamePerPlugin<P>> = { | ||
[key in keyof Names & keyof P & `${number}` as Names[key]]: P[key] extends Plugin<any, any, any, infer Info> ? Info : never; | ||
}; | ||
type PluginPerPlugin<P extends readonly Plugin[], Names extends NamePerPlugin<P> = NamePerPlugin<P>> = { | ||
[key in keyof Names & keyof P & `${number}` as Names[key]]: P[key] extends Plugin ? P[key] : never; | ||
}; | ||
type KeysOfType<O, T> = { | ||
[K in keyof O]: T extends O[K] ? K : never | ||
}[keyof O]; | ||
export type SimpleTranslationFunction<LocaleDoc extends Record<string, any>> = { | ||
(key: LocaleKey<LocaleDoc>): string; | ||
}; | ||
type FlatType<T> = T extends object ? { [K in keyof T]: FlatType<T[K]> } : T; | ||
export type TranslationFunction< | ||
LocaleDoc extends Record<string, any>, | ||
P extends readonly Plugin[] | ||
> = { | ||
/** | ||
* Translate a key from a translation document | ||
* | ||
* @param key a key to translate from | ||
* @param args optional parameters for plugins used for the chosen key | ||
*/ | ||
< | ||
K extends LocaleKey<LocaleDoc>, | ||
PluginKey extends KeysOfType<MatchPerPlugin<P>, LocaleDoc[K]> = KeysOfType<MatchPerPlugin<P>, LocaleDoc[K]>, | ||
_Signature = FlatType<PluginRegistry<LocaleDoc, K, InfoPerPlugin<P>[PluginKey], PluginPerPlugin<P>>[PluginKey]['signature']> | ||
>( | ||
key: K, | ||
...args: PluginRegistry<LocaleDoc, K, InfoPerPlugin<P>[PluginKey], PluginPerPlugin<P>>[PluginKey]['args'] | ||
): string; | ||
} |
@@ -1,96 +0,157 @@ | ||
import { Translation, LocaleInputParameter, LocaleKey, TranslationProxy, LocaleOptionsParameter } from '../ts.schema'; | ||
import { LocaleKey } from '../ts.schema'; | ||
export type PluginHook<Locale extends Translation, Processors> = ( | ||
this: { | ||
translate: TranslationProxy<Locale, Processors> | ||
callHook: ( | ||
hook: PluginHooks, | ||
value?: unknown | ||
) => string | undefined; | ||
}, | ||
value: unknown | undefined, | ||
input: LocaleInputParameter<Locale, LocaleKey<Locale>, Processors> | undefined, | ||
parameter: LocaleOptionsParameter<Locale, LocaleKey<Locale>, Processors> | undefined, | ||
currentLocaleId: () => Intl.Locale | undefined, | ||
key: string, | ||
translationDocument: Locale | undefined, | ||
initiatorPlugin?: string | undefined | ||
) => string | undefined; | ||
/** | ||
* Opt-in global plugin registry, | ||
* tracks all plugins included throughout the project to simplify type-checking | ||
*/ | ||
export interface PluginRegistry< | ||
Locale extends Record<string, any> = Record<string, any>, | ||
Key extends LocaleKey<Locale> = LocaleKey<Locale>, | ||
PluginInfo = unknown, | ||
ContextualPlugins extends Record<string, Plugin> = Record<string, Plugin> | ||
> { | ||
[name: string]: { | ||
/** | ||
* Arguments to require when translating a key that matches this plugin | ||
* | ||
* Should be a named tuple | ||
*/ | ||
args: unknown[]; | ||
export interface Plugin<Locale extends Translation, Processors> { | ||
name: string; | ||
initPlugin?: PluginHook<Locale, Processors>; | ||
docNotFound?: PluginHook<Locale, Processors>; | ||
keyNotFound?: PluginHook<Locale, Processors>; | ||
keyFound?: PluginHook<Locale, Processors>; | ||
processorFound?: PluginHook<Locale, Processors>; | ||
processorNotFound?: PluginHook<Locale, Processors>; | ||
keyProcessed?: PluginHook<Locale, Processors>; | ||
keyNotProcessed?: PluginHook<Locale, Processors>; | ||
/** | ||
* Miscellanious information that the plugin uses | ||
* | ||
* This is a generic type that is then used as a type guard in PluginRegistry evaluation | ||
*/ | ||
info?: unknown; | ||
/** | ||
* This is displayed in a type hint when the user hovers over the translation function invocation | ||
* | ||
* Allows to display any important information (for example, original key signature) to the user | ||
*/ | ||
signature?: unknown; | ||
}; | ||
} | ||
type PluginHooks = keyof Omit<Plugin<any, any>, 'name'>; | ||
/** | ||
* An interface that other plugins use | ||
* to reference another plugin in their code | ||
*/ | ||
export interface PluginInterface< | ||
LocaleDoc extends Record<string, any> = Record<string, any>, | ||
Key extends LocaleKey<LocaleDoc> = LocaleKey<LocaleDoc>, | ||
Name extends keyof PluginRegistry<LocaleDoc> = keyof PluginRegistry<LocaleDoc>, | ||
> { | ||
translate(key: LocaleKey<LocaleDoc>, ...args: PluginRegistry<LocaleDoc, Key>[Name]['args']): string | undefined; | ||
match(value: unknown, key: string, doc: Record<string, unknown>): boolean; | ||
info: Exclude<PluginRegistry<LocaleDoc, Key>[Name]['info'], undefined>; | ||
} | ||
export const callPlugins = <Locale extends Translation, Processors>( | ||
translate: TranslationProxy<Locale, Processors>, | ||
plugins: Plugin<Locale, Processors>[] = [], | ||
) => { | ||
const pluginsPerHook = plugins.reduce((obj, plugin) => { | ||
for (const _hookName in plugin) if (typeof plugin[_hookName as PluginHooks] === 'function') { | ||
const hookName = _hookName as PluginHooks; | ||
const hook = plugin[hookName] as PluginHook<Locale, Processors>; | ||
/** | ||
* Context of the plugin's `translate` function | ||
*/ | ||
export interface PluginContext< | ||
Match = any, | ||
LocaleDoc extends Record<string, any> = Record<string, any>, | ||
Key extends LocaleKey<LocaleDoc> = LocaleKey<LocaleDoc>, | ||
Name extends keyof PluginRegistry = string, | ||
> { | ||
name: Name; | ||
key: Key; | ||
value: Match; | ||
doc: LocaleDoc; | ||
originalCallArgs: unknown[]; | ||
originalKey: LocaleKey<LocaleDoc>; | ||
originalValue: unknown; | ||
translate(key: LocaleKey<LocaleDoc>, ...args: unknown[]): string; | ||
plugins: { | ||
[name in keyof PluginRegistry<LocaleDoc, Key>]?: PluginInterface<LocaleDoc, Key, name>; | ||
}; | ||
} | ||
if (hookName in obj) { | ||
obj[hookName].push(hook); | ||
} else { | ||
obj[hookName] = [hook]; | ||
} | ||
} | ||
export interface Plugin< | ||
Match = any, | ||
Args extends any[] = any, | ||
Name extends keyof PluginRegistry = string, | ||
PluginInfo = unknown, | ||
LocaleDoc extends Record<string, any> = Record<string, any>, | ||
Key extends LocaleKey<LocaleDoc> = LocaleKey<LocaleDoc>, | ||
> { | ||
name: Name; | ||
info: PluginInfo; | ||
match(value: unknown, key: Key, doc: LocaleDoc): value is Match; | ||
translate(this: PluginContext<Match, LocaleDoc, Key, Name>, ...args: Args): string | undefined; | ||
} | ||
return obj; | ||
}, {} as Record<PluginHooks, PluginHook<Locale, Processors>[]>); | ||
const callPluginsForHook = (hook: PluginHooks, ...[ | ||
value, | ||
input, | ||
parameter, | ||
currentLocaleId, | ||
key, | ||
doc, | ||
initiatorPlugin | ||
]: Parameters<PluginHook<Locale, Processors>>): string | undefined => { | ||
if (!pluginsPerHook[hook]) { | ||
return value == null ? undefined : String(value); | ||
/** | ||
* A plugin factory, mostly used for type-checking | ||
* | ||
* @param name A name for the plugin, will be used as a global plugin registry shortcut for other plugins, | ||
* must match with the name used in the plugin registry definition | ||
* | ||
* @param match A {@link https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates type predicate} | ||
* that decides whether or not the plugin should be used on a specific key-value pair | ||
* | ||
* @param options Allows to define the functionality of a plugin. It can do 2 things: | ||
* 1. Provide info and context to other plugins, using the `info` property | ||
* 2. Provide additional ways of translating a key-value pair from a translation document, using the `translate` method | ||
* | ||
* @returns a ready-to-use plugin | ||
*/ | ||
export const createPlugin: { | ||
/** | ||
* A plugin factory, mostly used for type-checking | ||
* | ||
* @param name A name for the plugin, will be used as a global plugin registry shortcut for other plugins, | ||
* must match with the name used in the plugin registry definition | ||
* | ||
* @param match A {@link https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates type predicate} | ||
* that decides whether or not the plugin should be used on a specific key-value pair | ||
* | ||
* @param options Allows to define the functionality of a plugin. It can do 2 things: | ||
* 1. Provide info and context to other plugins, using the `info` property | ||
* 2. Provide additional ways of translating a key-value pair from a translation document, using the `translate` method | ||
* | ||
* @returns a ready-to-use plugin | ||
*/ | ||
<Name extends keyof PluginRegistry, Match, PluginInfo, Args extends any[] = PluginRegistry[Name]['args']>( | ||
name: Name, | ||
match: (value: unknown) => value is Match, | ||
options: { | ||
info?: PluginInfo, | ||
translate?: (this: PluginContext<Match>, ...args: Args) => string | undefined, | ||
} | ||
): Plugin<Match, Args, Name, PluginInfo>; | ||
let val = value; | ||
for (const pluginHook of pluginsPerHook[hook]) { | ||
const pluginResult = pluginHook.call({ | ||
callHook(_hook, value) { | ||
if (hook === _hook) { | ||
// Prevent recursion | ||
return; | ||
} | ||
return callPluginsForHook(_hook, value, input, parameter, currentLocaleId, key, doc, pluginHook.name); | ||
}, | ||
translate | ||
}, val, input, parameter, currentLocaleId, key, doc, initiatorPlugin); | ||
if (typeof pluginResult === 'string') { | ||
return pluginResult; | ||
} | ||
if (pluginResult != null) { | ||
val = pluginResult; | ||
} | ||
/** | ||
* A plugin factory, mostly used for type-checking | ||
* | ||
* @param name A name for the plugin, will be used as a global plugin registry shortcut for other plugins, | ||
* must match with the name used in the plugin registry definition | ||
* | ||
* @param match A {@link https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates type predicate} | ||
* that decides whether or not the plugin should be used on a specific key-value pair | ||
* | ||
* @param options Allows to define the functionality of a plugin. | ||
* It can provide additional ways of translating a key-value pair | ||
* from a translation document, using the `translate` method | ||
* | ||
* @returns a ready-to-use plugin | ||
*/ | ||
<Match, Args extends any[]>( | ||
name: string, | ||
match: (value: unknown) => value is Match, | ||
options: { | ||
translate?: (this: PluginContext<Match>, ...args: Args) => string | undefined, | ||
} | ||
): Plugin<Match, Args>; | ||
} = <Match, Args extends any[]>( | ||
name: string, | ||
match: (value: unknown) => value is Match, | ||
options: { | ||
info?: unknown, | ||
translate?: (this: PluginContext<Match>, ...args: Args) => string | undefined, | ||
} | ||
): Plugin<Match, Args> => ({ name, match, translate: options.translate ?? (() => undefined), info: options.info }); | ||
return val == null ? undefined : String(val); | ||
}; | ||
return callPluginsForHook; | ||
}; | ||
export const createPlugin = <T extends Plugin<any, any>>(plugin: T): T => plugin; |
@@ -1,11 +0,21 @@ | ||
import { ArrayRecordPlugin } from './array-record'; | ||
import { ObjectRecordPlugin } from './object-record'; | ||
import { ProcessorPlugin } from './processed-record'; | ||
import { ResolveMissingKeyPlugin } from './resolve-missing'; | ||
import { LocaleProviderPlugin } from './locale'; | ||
import { ArraysPlugin } from './arrays'; | ||
import { Processors, ProcessorsPlugin } from './processors/plugin'; | ||
import { defaultProcessors } from './processors'; | ||
export const defaultPlugins = [ | ||
ProcessorPlugin, | ||
ArrayRecordPlugin, | ||
ObjectRecordPlugin, | ||
ResolveMissingKeyPlugin, | ||
]; | ||
/** | ||
* Default schematic plugins | ||
* | ||
* Allow to: | ||
* - Access a user's locale in other plugins | ||
* - Process translation keys with custom processors | ||
* - Join and cross-reference translation records using arrays and object | ||
*/ | ||
export const defaultPlugins = <P extends Processors = typeof defaultProcessors>( | ||
currentLocale: () => Intl.Locale | undefined, | ||
processors: P | ||
) => [ | ||
LocaleProviderPlugin(currentLocale), | ||
ArraysPlugin, | ||
ProcessorsPlugin(processors), | ||
] as const; |
@@ -1,73 +0,9 @@ | ||
import type { | ||
PlainStringTranslationRecord, | ||
ParametrizedTranslationRecord, | ||
PlainStringTranslationRecordWithReferences, | ||
} from './translation.schema'; | ||
import type { InputParameter, OptionsParameter } from './processors'; | ||
export interface TranslationModule { | ||
[k: string]: | ||
| PlainStringTranslationRecord | ||
| ParametrizedTranslationRecord | ||
| PlainStringTranslationRecordWithReferences | ||
| ((...args: any[]) => string); | ||
export interface TranslationDocuemnt { | ||
[key: string]: unknown; | ||
} | ||
export type Translation = TranslationModule; | ||
export type LocaleKey<Locale extends TranslationDocuemnt> = Exclude<keyof Locale, '$schema'>; | ||
export type LocaleKey<Locale extends Translation> = Extract<keyof Omit<Locale, '$schema'>, string>; | ||
type ExtraPartial<I> = { | ||
export type ExtraPartial<I> = { | ||
[P in keyof I]?: I[P] | null | undefined; | ||
}; | ||
export type LocaleInputParameter< | ||
Locale extends Translation, | ||
K extends LocaleKey<Locale>, | ||
P extends Processors, | ||
> = null | ( | ||
Locale[K] extends { processor: infer O; input: infer I; } | ||
? keyof O extends keyof P | ||
? ExtraPartial<InputParameter<P, keyof O>> | ||
: ExtraPartial<I> | ||
: Locale[K] extends Array<Record<infer Key, any> | string> | ||
? { [key in LocaleKey<Locale> & Key]?: LocaleInputParameter<Locale, key, P>; } | ||
: Locale[K] extends Record<infer Key, any> | ||
? { [key in LocaleKey<Locale> & Key]?: LocaleInputParameter<Locale, key, P>; } | ||
: string | ||
); | ||
export type LocaleOptionsParameter< | ||
Locale extends Translation, | ||
K extends LocaleKey<Locale>, | ||
P extends Processors, | ||
> = null | ( | ||
Locale[K] extends { processor: infer O; parameter: infer I; } | ||
? keyof O extends keyof P | ||
? ExtraPartial<OptionsParameter<P, keyof O>> | ||
: ExtraPartial<I> | ||
: Locale[K] extends Record<string, any> | ||
? { [key in LocaleKey<Locale> & keyof Locale[K]]?: LocaleOptionsParameter<Locale, key, P>; } | ||
: string | ||
); | ||
export type TranslationFunction<Locale extends Translation, P extends Processors, R = string> = { | ||
/** | ||
* A translation function, looks for a specified key in the local translation document to provide a localized string. | ||
* @param key | ||
* - a key from a translation document (usually `{locale}.json`) | ||
* @param input | ||
* -- a default value (in case a plain-string translation for the key isn't found) | ||
* - an input parameter, if the locale key is parametrized | ||
* @returns a translated string | ||
*/ | ||
<K extends LocaleKey<Locale>>( | ||
key: K, | ||
input?: Locale[K] extends (...args: infer A) => string | ||
? { args: A } | ||
: LocaleInputParameter<Locale, K, P> | null, | ||
parameter?: LocaleOptionsParameter<Locale, K, P>, | ||
): R; | ||
}; | ||
export type TranslationProxy<Locale extends Translation, P extends Processors> = TranslationFunction<Locale, P>; |
@@ -12,4 +12,4 @@ { | ||
"anyOf": [{ | ||
"title": "Reference to an input of another paramterized record", | ||
"description": "Value must refer to a paramterized record that is already referenced in this array", | ||
"title": "Reference to an input of another parametrized record", | ||
"description": "Value must refer to a parametrized record that is already referenced in this array", | ||
"type": "string", | ||
@@ -16,0 +16,0 @@ "pattern": "input:.+" |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
66713
52
1572
83
1