Skip to content

Commit

Permalink
feature: Add ICU i18n support
Browse files Browse the repository at this point in the history
  • Loading branch information
fabschurt committed Apr 16, 2024
1 parent fe567bf commit da7a038
Show file tree
Hide file tree
Showing 10 changed files with 276 additions and 27 deletions.
61 changes: 61 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"dependencies": {
"docopt": "^0.6.2",
"dotenv": "^16.3.1",
"intl-messageformat": "^10.5.11",
"pug": "^3.0.2"
}
}
14 changes: 14 additions & 0 deletions src/adapter/icu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import assert from 'node:assert'

export const translateString = (
(IntlMessageFormat, dictionary, defaultLocale = 'en') => (
(msgID, params = {}, locale = defaultLocale) => {
assert(
msgID in dictionary,
`The messsage with ID \`${msgID}\` does not exist in the dictionary.`,
)

return new IntlMessageFormat(dictionary[msgID], locale).format(params)
}
)
)
7 changes: 5 additions & 2 deletions src/domain/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const TRANSLATION_DIR_PATH = 'i18n'
const TRANSLATION_FILE_EXT = '.json'

export const parseProjectTranslations = (
(parseJSONFile, withSrcDir) => (
(parseJSONFile, withSrcDir, dotFlattenObject) => (
(lang) => {
assert.match(lang, LANG_PATTERN, `\`${lang}\` is not a valid language code.`)

Expand All @@ -16,7 +16,10 @@ export const parseProjectTranslations = (
lang + TRANSLATION_FILE_EXT,
)

return parseJSONFile(translationFilePath)
return (
parseJSONFile(translationFilePath)
.then(dotFlattenObject)
)
})
}
)
Expand Down
17 changes: 5 additions & 12 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import {
cleanUpObjectList,
mergeObjectList,
accessObjectProp,
dotFlattenObject,
} from '#src/utils/object'
import { renderTemplate } from '#src/adapter/pug'
import { format } from 'node:util'
import { translateString } from '#src/adapter/icu'
import { render as renderPug } from 'pug'
import { IntlMessageFormat } from 'intl-messageformat'

export default async function main(
srcDirPath,
Expand Down Expand Up @@ -49,17 +51,8 @@ export default async function main(
,
lang
? (
parseProjectTranslations(_parseJSONFile, withSrcDir)(lang)
.then((dictionary) => ({
_: {
trans: (msgID, ...args) => (
format(
accessObjectProp(dictionary, msgID),
...args,
)
)
},
}))
parseProjectTranslations(_parseJSONFile, withSrcDir, dotFlattenObject)(lang)
.then((dictionary) => ({ _t: translateString(IntlMessageFormat, dictionary, lang) }))
)
: {}
,
Expand Down
18 changes: 18 additions & 0 deletions src/utils/object.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,21 @@ export const accessObjectProp = (obj, propPath) => {
: obj[currentKey]
)
}

export const dotFlattenObject = (obj) => {
assert(valueIsComposite(obj), 'Only composite values can be flattened.')

const output = {}

Object.entries(obj).forEach(([prop, val]) => {
if (valueIsComposite(val)) {
Object.entries(dotFlattenObject(val)).forEach(([subProp, subVal]) => {
output[`${prop}.${subProp}`] = subVal
})
} else {
output[prop] = val
}
})

return output
}
72 changes: 72 additions & 0 deletions tests/adapter/icu.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { translateString } from '#src/adapter/icu'
import { IntlMessageFormat } from 'intl-messageformat'

describe('#src/adapter/icu', () => {
describe('translateString()', () => {
describe('translates an ICU-formatted string using a static dictionary', () => {
const dictionary = {
greetings: 'Hello, my name is {name}.',
occupation: 'I’m a {job}, the best in {town}.',
number_of_kids: 'I have {kidNum, plural, =0 {no kids} =1 {a single child} other {# kids}}.',
bastille_day: 'France’s Bastille Day was on {bastilleDay, date, ::yyyyMMMMdd}.',
account_balance: 'I have {balance, number} moneyz on my bank account.'
}

const _t = translateString(IntlMessageFormat, dictionary)

it('supports param placeholders', () => {
assert.strictEqual(
_t('greetings', { name: 'John Smith' }),
'Hello, my name is John Smith.',
)
assert.strictEqual(
_t('occupation', { job: 'smith', town: 'Smithtown' }),
'I’m a smith, the best in Smithtown.',
)
})

it('supports plural form', () => {
assert.strictEqual(
_t('number_of_kids', { kidNum: 0 }),
'I have no kids.',
)
assert.strictEqual(
_t('number_of_kids', { kidNum: 1 }),
'I have a single child.',
)
assert.strictEqual(
_t('number_of_kids', { kidNum: 2 }),
'I have 2 kids.',
)
})

it('supports locale-specific date formatting', () => {
const bastilleDay = new Date('1789-07-14')

assert.strictEqual(
_t('bastille_day', { bastilleDay }, 'en-US'),
'France’s Bastille Day was on July 14, 1789.',
)
assert.strictEqual(
_t('bastille_day', { bastilleDay }, 'fr-FR'),
'France’s Bastille Day was on 14 juillet 1789.',
)
})

it('supports locale-specific number formatting', () => {
const balance = 12345.67

assert.strictEqual(
_t('account_balance', { balance }, 'en-US'),
'I have 12,345.67 moneyz on my bank account.',
)
assert.strictEqual(
_t('account_balance', { balance }, 'fr-FR'),
'I have 12 345,67 moneyz on my bank account.',
)
})
})
})
})
57 changes: 50 additions & 7 deletions tests/domain/i18n.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,75 @@ import * as fs from 'node:fs/promises'
import { join } from 'node:path'
import { withDir, ifPathExists, readFile } from '#src/utils/fs'
import { parseJSONFile } from '#src/utils/json'
import { dotFlattenObject } from '#src/utils/object'
import { parseProjectTranslations } from '#src/domain/i18n'

describe('#src/domain/i18n', () => {
describe('parseProjectTranslations()', () => {
it('parses a translation file from a predefined directory', async () => {
it('parses ICU-formatted translation messages from a predefined directory', async () => {
await withTempDir(async (prefixWithTempDir) => {
const srcDirPath = prefixWithTempDir('src')
const translationDirPath = join(srcDirPath, 'i18n')
const file1Path = join(translationDirPath, 'fr.json')
const file2Path = join(translationDirPath, 'en.json')

await fs.mkdir(translationDirPath, { recursive: true })
await fs.writeFile(file1Path, '{"greetings": "Bonjour"}')
await fs.writeFile(file2Path, '{"greetings": "Hello"}')
await fs.writeFile(file1Path, `
{
"greetings": "Bonjour",
"info": {
"secret": {
"astro_sign": "Sagittaire"
},
"goals": [
"Effectuer le dab",
"Jouer à «The Binding of Isaac»"
]
}
}
`)
await fs.writeFile(file2Path, `
{
"greetings": "Hello",
"info": {
"secret": {
"astro_sign": "Sagittarius"
},
"goals": [
"Dabbing like hell",
"Playing «The Binding of Isaac»"
]
}
}
`)

const withSrcDir = withDir(srcDirPath)
const _parseJSONFile = parseJSONFile(ifPathExists, readFile)
const _parseProjectTranslations = parseProjectTranslations(_parseJSONFile, withSrcDir)
const _parseProjectTranslations = (
parseProjectTranslations(
_parseJSONFile,
withSrcDir,
dotFlattenObject,
)
)

assert.deepStrictEqual(
await _parseProjectTranslations('fr'),
{ greetings: 'Bonjour' },
{
'greetings': 'Bonjour',
'info.secret.astro_sign': 'Sagittaire',
'info.goals.0': 'Effectuer le dab',
'info.goals.1': 'Jouer à «The Binding of Isaac»',
},
)
assert.deepStrictEqual(
await _parseProjectTranslations('en'),
{ greetings: 'Hello' },
{
'greetings': 'Hello',
'info.secret.astro_sign': 'Sagittarius',
'info.goals.0': 'Dabbing like hell',
'info.goals.1': 'Playing «The Binding of Isaac»',
},
)
assert.deepStrictEqual(
await _parseProjectTranslations('de'),
Expand All @@ -41,7 +84,7 @@ describe('#src/domain/i18n', () => {

it('throws if an invalid `lang` parameter is passed', () => {
assert.throws(
() => parseProjectTranslations(noop, noop)('fAiL'),
() => parseProjectTranslations(noop, noop, noop)('fAiL'),
assert.AssertionError,
)
})
Expand Down
14 changes: 8 additions & 6 deletions tests/main.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,19 @@ describe('#src/main', () => {
await fs.writeFile(dataFilePath, `
{
"id": {
"first_name": "John",
"last_name": "Smith",
"age": 35,
"firstName": "John",
"lastName": "Smith"
},
"age": 35,
"location": {
"city": "%SECRET_CITY%"
}
}
`)
await fs.writeFile(transFilePath, `
{
"greetings": "Hello",
"full_name": "%s %s",
"fullName": "{firstName} {lastName}",
"occupation": {
"dev": "developer",
"fireman": "firefighter"
Expand All @@ -57,8 +59,8 @@ html
head
title Some meaningless title
body
p #{_.trans('greetings')}! I’m #{_.trans('full_name', id.first_name, id.last_name)}, I’m #{id.age} years old, and I live in #{id.city}.
p I work as a #{_.trans('occupation.dev')}, but I’ve always dreamt about being a #{_.trans('occupation.fireman')}.
p #{_t('greetings')}! I’m #{_t('fullName', { firstName: id.firstName, lastName: id.lastName })}, I’m #{age} years old, and I live in #{location.city}.
p I work as a #{_t('occupation.dev')}, but I’ve always dreamt about being a #{_t('occupation.fireman')}.
`)
await fs.writeFile(join(srcDirPath, mainJSFileBasePath), mainJSFileContent)
await fs.writeFile(join(srcDirPath, mainCSSFileBasePath), mainCSSFileContent)
Expand Down
Loading

0 comments on commit da7a038

Please sign in to comment.