Skip to content

Commit

Permalink
composition
Browse files Browse the repository at this point in the history
  • Loading branch information
eddow committed May 14, 2024
1 parent 719c82f commit fcdbe98
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 125 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ In this case, _both_ `T.fld.name` _and_ `T.fld.name.short` will retrieve `"Name"
If we take the examples of `en-GB` and `en-US`, four locales are going to be used: `en-GB` and `en-US` of course, `en` who will take care of all the common english texts and `''` (the empty-named local) who contains technical things common to all languages.
So, downloading `en-US` will download `''` overwritten with `en` then overwritten with `en-US`.

Common things are formats for example: `format.price: '{number|$2|style: currency, currency: $1}'` for prices allowing `T.format.price(currency, amount)`
Common things are formats for example: `format.price: '{number::$2|style: currency, currency: $1}'` for prices allowing `T.format.price(currency, amount)`

#### Fallbacks

Expand Down Expand Up @@ -129,9 +129,9 @@ It heavily relies on the [hard-coded Intl](https://developer.mozilla.org/en-US/d
Examples:

```
Hello {=1|here}
There {plural|$1|is|are} {number|$1} {plural|$1|entry|entries}
{number| $price | style: currency, currency: $currency}
Hello {$1|here}
There {plural::$1|is|are} {number::$1} {plural::$1|entry|entries}
{number:: $price | style: currency, currency: $currency}
```

## Error reporting
Expand Down
10 changes: 10 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,13 @@ Projects using OmnI18n use it in 4 layers
2. (optional) The HTTP or any other layer. This part is implemented by the user
3. [The `server`](./server.md): The server exposes functions to interact with the languages
4. [The `database`](./db.md): A class implementing some interface that interacts directly with a database

## Bonus - flags

```js
import { localeFlags, flagCodeExceptions }
localeFlags('en-GB') // ['🇬🇧']
localeFlags('en-US') //['🇬🇧', '🇺🇸']
flagCodeExceptions.en = '🏴󠁧󠁢󠁥󠁮󠁧󠁿'
localeFlags('en-GB') // ['🏴󠁧󠁢󠁥󠁮󠁧󠁿', '🇬🇧']
```
3 changes: 2 additions & 1 deletion docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ reports.missing = ({ key, client }: TContext, fallback?: string) => {
return fallback ?? `[${key}]`
}
```

> The fallback comes from a locale that was specified in the list the client' locale but was not first-choice
- An interpolation error
Expand Down Expand Up @@ -92,4 +93,4 @@ When the application knows well it enters several zones while doing an action (l

For this, **after** SSR-rendering, `payload = SSC.getPartialLoad(excludedZones: Zone[] = [])` can be called with the list of zones the CSC **already** possess. It will return a completely json-able in a compact format of the loaded dictionary

This partial answer can be conveyed in the answer with the action' results (especially useful in a page load action) and given to the CSC with `CSC.usePartial(payload)`
This partial answer can be conveyed in the answer with the action' results (especially useful in a page load action) and given to the CSC with `CSC.usePartial(payload)`
1 change: 1 addition & 0 deletions docs/db.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ This allows:
#### Recovering a file to export to a database
An `FileDB.analyze` function is exposed who takes the string to analyze and 2/3 callbacks
- `onKey` called when a new key is discovered
- `onText` called when a translation is discovered
- `endKey?` called when the key is finished
Expand Down
65 changes: 44 additions & 21 deletions docs/interpolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,17 @@ The function call will return a pure string and can take arguments.

The interpolation is done in `I18nClient::interpolate` and can of course be overridden.

## Arguments

> :information_source: While interpolating, the argument nr 0 is the key, the first argument is the argument nr 1. This is meant to be used by translators - literacy peeps - so of course the first argument has the number "1".
The interpolation engine is linear. Reg-exp like if you wish, meaning it is _not_ a programming language but is meant to be as flexible as possible in its limitations

`"This is a {=1}"` will have to be called with an argument, `"This is a {=1|distraction}"` may be called with an argument.
Everything in between `{...}` is interpolated. In order to have a curly-brace in the text, those have to be doubled. `Look at my left brace -> {{`.
In the braces, every character is escaped with the `\` character, even the `\` character

If the content does not begin with the `=` sign, the content is a list separated by `|` where each element can be :
Several values can be given for defaulting:
`"This is a {$1}"` will have to be called with an argument, `"This is a {$1|distraction}"` may be called with an argument.

- A string
- An flat named list in the shape `key1: value1, key2: value2` where only `,` and `:` are used for the syntax.
## Arguments

> The `:` character triggers the list parsing. In order to used a ":" in a string, it has to be doubled "::" - The coma is escaped the same way: ",,"
> :information_source: While interpolating, the argument nr 0 is the key, the first argument is the argument nr 1. This is meant to be used by translators - literacy peeps - so of course the first argument has the number "1".
The parameters (given in the code) can be accessed as such:
First, the last parameter is the one used for naming. If a named parameter is accessed, the last (or only) parameter should be an object with named properties
Expand All @@ -28,25 +27,31 @@ First, the last parameter is the one used for naming. If a named parameter is ac

To add a default, `$arg[default value]` can be used, as well as `$[name: John]`

To use the `$` character, it just has to be doubled: `$$`
Also, a sub-translation can be made with `$.text.key`

## Processing

Processing occurs with the `::` operator, as `{process :: arg1 | arg2 | ...}` where the arguments can be:

The first element will determine how the whole `{...}` will be interpolated
- A string
- An flat named list in the shape `key1: value1, key2: value2` where only `,` and `:` are used for the syntax.
> The `:` character triggers the list parsing.
## List cases
### List cases

If the first element is a named list, the second one will be the case to take from the list.

example: `{question: ?, exclamation: !, default: ... | $1}`

> :information_source: The case `default` get the remaining cases and, if not specified, an error is raised if an inexistent case is given
## Sub translation
### Sub translation

To use another translation can be useful, when for example one translation is a number format centralization common to all languages, or when a centralized (all-language) format string needs to use conjunctions or words that are language-specific.

The syntax `{other.text.key | arg1 | arg2}` can be used to do such.

## Processors
### Processors

The syntax also allow some processing specification, when a processor name (with no `.` in it) is used instead of a first element. The available processors can be extended :

Expand All @@ -71,13 +76,13 @@ example: `{upper | $1}` will render the first argument in upper-case

> :information_source: `{$2[upper] | $1}` is also possible, in which case the second argument can both specify a text key, a processor or be defaulted to the `upper` processor.
### Casing
#### Casing

- `upper(s)`
- `lower(s)`
- `title(s)`: uppercase-first

### Numeric formatting
#### Numeric formatting

- `number(n, opt?)`: equivalent to `Intl.NumberFormat()` who receive the list `opt` as options
- `date(n, opt?)`: equivalent to `Intl.DateTimeFormat()` who receive the list `opt` as options
Expand All @@ -91,14 +96,14 @@ formats.date.year = { year: 'numeric' }
formats.number.arabic = { numberingSystem: 'arab' }

const client: I18nClient = ...;
client.interpolate({key: '*', zones: [], client}, '{date|$0|year}', new Date('2021-11-01T12:34:56.789Z')); // 2021
client.interpolate({key: '*', zones: [], client}, '{date|$0|month: numeric}', new Date('2021-11-01T12:34:56.789Z')); // 11
client.interpolate({key: '*', zones: [], client}, '{date::$0|year}', new Date('2021-11-01T12:34:56.789Z')); // 2021
client.interpolate({key: '*', zones: [], client}, '{date::$0|month: numeric}', new Date('2021-11-01T12:34:56.789Z')); // 11
```

Also, each client has a property `timeZone`. If set, it will be the default `timeZone` used in the options.
Its format is the one taken by `Date.toLocaleString()`

### Other hard-coded
#### Other hard-coded

We of course speak about the ones hard-coded in the Intl javascript core of Node and the browsers.

Expand All @@ -125,6 +130,24 @@ The keywords (`one`, `other`, ...) come from [`Intl.PluralRules`](https://develo

- `ordinal(n)` To display "1st", "2nd", ...
- `plural(n, spec)`:
- If `spec` is a word, the `internals.plurals` rule is used (`{plural|1|cat}`-> "cat", `{plural|2|cat}`-> "cats").
- The specification can use the `Intl.PluralRules` (ex: `{plural|$1|one:ox,other:oxen}`)
- A specific case is made for languages who use `one/other` (like english) : `{plural|$1|ox|oxen}`
- If `spec` is a word, the `internals.plurals` rule is used (`{plural::1|cat}`-> "cat", `{plural::2|cat}`-> "cats").
- The specification can use the `Intl.PluralRules` (ex: `{plural::$1|one:ox,other:oxen}`)
- A specific case is made for languages who use `one/other` (like english) : `{plural::$1|ox|oxen}`

### Complex processing structures

Something like `{upper::relative::$1|short}` means "the relative time given as parameter, presented in a short format and upper case". The composition is applied _right to left_.

To translate from the interpolation format to a structured language, we could say that:

```
a1 | a2 :: b1 | b2 :: c1 | c2
```

is equivalent to

```js
a1(b1(c1, c2), b2) || a2
```

Where every part can contain `$...` replacements
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "omni18n",
"version": "1.1.0",
"version": "1.1.1",
"description": "",
"main": "dist/index.js",
"module": "dist/index.js",
Expand Down
163 changes: 92 additions & 71 deletions src/client/interpolation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { reportMissing, reportError, translate } from './helpers'
import { TContext, TranslationError } from './types'

// TODO: .text.key to react as $arg => sub-translation ?
// TODO: Multiple defaults ? (= $1 | $price | 'unknown')

export const formats: Record<'date' | 'number' | 'relative', Record<string, object>> = {
date: {
date: {
Expand Down Expand Up @@ -145,90 +142,114 @@ export const processors: Record<string, (...args: any[]) => string> = {
}
}

function objectArgument(arg: any): string | Record<string, string> {
function objectArgument(
arg: any,
unescape: (s: string) => string
): string | Record<string, string> {
if (typeof arg === 'object') return arg
// Here we throw as it means the code gave a wrong argument
if (typeof arg !== 'string') throw new TranslationError(`Invalid argument type: ${typeof arg}`)
if (!/[^:]:/.test(arg)) return arg.replace(/::/g, ':')
if (!/:/.test(arg)) return arg
return Object.fromEntries(
arg
.replace(/::/g, '\u0000')
.replace(/,,/g, '\u0004')
.split(',')
.map((part) =>
part.split(':', 2).map((part) =>
part
.replace(/\u0000/g, ':')
.replace(/\u0004/g, ',')
.trim()
)
)
arg.split(',').map((part) => part.split(':', 2).map((part) => unescape(part.trim())))
)
}

/*
Escapement characters:
u0001: {
u0002: }
u0003: \n
u0004-u0005: escapement parenthesis
*/

export function interpolate(context: TContext, text: string, args: any[]): string {
const { key, zones } = context
function arg(i: string | number, dft?: string) {
if (typeof i === 'string' && /^\d+$/.test(i)) i = parseInt(i)
if (i === 0) return key
const lastArg = args[args.length - 1],
val =
i === ''
? lastArg
: typeof i === 'number'
? args[i - 1]
: typeof lastArg === 'object' && i in lastArg
? lastArg[i]
: undefined
if (val instanceof Date) return '' + val.getTime()
if (typeof val === 'object')
return Object.entries(val)
.map(([key, value]) => `${key}: ${value}`)
.join(', ')
if (typeof val === 'number') return '' + val
return val !== undefined
? val
: dft !== undefined
? dft
: reportError(context, 'Missing arg', { arg: i, key })
}
text = text.replace(/{{/g, '\u0001').replace(/}}/g, '\u0002')
text = text.replace(/\\{/g, '\u0001').replace(/\\}/g, '\u0002').replace(/\n/g, '\u0003')
const placeholders = (text.match(/{(.*?)}/g) || []).map((placeholder) => {
placeholder = placeholder
.slice(1, -1)
.replace(/\u0001/g, '{')
.replace(/\u0002/g, '}')
// Special {=1}, {=1|default} for "First argument" syntax
const simpleArg = /^=(\w*)(?:\s*\|\s*(.*)\s*)?$/.exec(placeholder)
if (simpleArg) return arg(simpleArg[1], simpleArg[2])
else {
const [proc, ...params] = placeholder
.split('|')
.map((part) => part.trim().replace(/\$\$/g, '\u0003'))
.map((part) =>
part.replace(/\$(\w*)(?:\[(.*?)\])?/g, (_, num, dft) =>
arg(num.replace(/\u0003/g, '$'), dft?.replace(/\u0003/g, '$'))
)
)
.map((part) => objectArgument(part))
if (typeof proc === 'object')
return params.length !== 1 || typeof params[0] !== 'string'
? reportError(context, 'Case needs a string case', { params })
: params[0] in proc
? proc[params[0]]
: 'default' in proc
? proc.default
: reportError(context, 'Case not found', { case: params[0], cases: proc })
if (proc.includes('.')) return translate({ ...context, key: proc }, params)
if (!(proc in processors)) return reportError(context, 'Unknown processor', { proc })
try {
return processors[proc].call(context, ...params)
} catch (error) {
return reportError(context, 'Error in processor', { proc, error })
const escapements: Record<string, string> = { '/': '/' },
unescapements: Record<number, string> = {}
let escapementCounter = 0
placeholder = placeholder.replace(/\\(.)/g, (_, c) => {
if (!escapements[c]) {
unescapements[escapementCounter] = c
escapements[c] = '\u0004' + escapementCounter++ + '\u0005'
}
return escapements[c]
})
function unescape(s: string) {
return s
.replace(/\u0003/g, '\n')
.replace(/\u0004([0-9]+)\u0005/g, (_, i) => unescapements[+i])
}

function useArgument(i: string | number, dft?: string) {
const { key } = context
if (typeof i === 'string' && /^\d+$/.test(i)) i = parseInt(i)
if (i === 0) return key
const lastArg = nextArgs[nextArgs.length - 1],
val =
i === ''
? lastArg
: typeof i === 'number'
? nextArgs[i - 1]
: typeof lastArg === 'object' && i in lastArg
? lastArg[i]
: undefined
if (val instanceof Date) return '' + val.getTime()
if (typeof val === 'object')
return Object.entries(val)
.map(([key, value]) => `${key}: ${value}`)
.join(', ')
if (typeof val === 'number') return '' + val
return val !== undefined ? val : dft !== undefined ? dft : '' //reportError(context, 'Missing arg', { arg: i, key })
}
function processPart(part: string) {
return objectArgument(
part
.trim()
.replace(/\$(\w+)(?:\[(.*?)\])?/g, (_, num, dft) => useArgument(num, dft))
.replace(/\$\.([\.\w]*)/g, (_, key) => translate({ ...context, key }, nextArgs))
.replace(/\$(?:\[(.*?)\])?/g, (_, dft) => useArgument('', dft)),
unescape
)
}
let nextArgs = args
const apps = placeholder.split('::').map((app) => app.split('|').map(processPart))
while (apps.length > 1) {
const params = apps.pop()!,
app = apps.pop()!,
[proc, ...others] = app
let processed: string | null = null
if (typeof proc === 'object') {
if (params.length !== 1 || typeof params[0] !== 'string')
return reportError(context, 'Case needs a string case', { params })
if (params[0] in proc) processed = proc[params[0]]
else if ('default' in proc) processed = proc.default
else return reportError(context, 'Case not found', { case: params[0], cases: proc })
} else if (proc.includes('.')) processed = translate({ ...context, key: proc }, params)
else if (!(proc in processors))
processed = reportError(context, 'Unknown processor', { proc })
else
try {
processed = processors[proc].call(context, ...params)
} catch (error) {
return reportError(context, 'Error in processor', { proc, error })
}
if (processed === null) throw Error(`Unprocessed case: ${proc}`)
apps.push((nextArgs = [processed, ...others]))
}
return apps[0].find((cas) => !!cas)
}),
parts = text.split(/{.*?}/).map((part) => part.replace(/\u0001/g, '{').replace(/\u0002/g, '}'))
parts = text.split(/{.*?}/).map((part) =>
part
.replace(/\u0001/g, '{')
.replace(/\u0002/g, '}')
.replace(/\u0003/g, '\n')
)

return parts.map((part, i) => `${part}${placeholders[i] || ''}`).join('')
}
4 changes: 2 additions & 2 deletions test/specifics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ describe('bulk', () => {

beforeAll(async () => {
const { Tp, client: lclClient } = localStack({
'obj.v1': { fr: 'fr-v1.{=parm}' },
'obj.v1': { fr: 'fr-v1.{$parm}' },
'obj.v2': { en: 'en-v2' },
'obj.v3': { fr: 'fr-v3' },
'struct.ok': { fr: 'fr-v1.{=parm}' },
'struct.ok': { fr: 'fr-v1.{$parm}' },
'struct.missing': { en: 'en-v2' },
'struct.sub.v3': { fr: 'fr-v3' },
'struct.sub': { fr: 'toString' }
Expand Down
Loading

0 comments on commit fcdbe98

Please sign in to comment.