(
+ key: K,
+ values?: T[K] extends (...args: infer P) => unknown ? Exact : never
+ ): string {
+ const usedTranslations = Array.isArray(translations) ? translations : [translations]
+ for (const translation of usedTranslations) {
+ const localizedTranslation = translation[locale]
+ if (!localizedTranslation) continue
+
+ const msg = localizedTranslation[key]
+ if (!msg) continue
+
+ if (typeof msg === 'string') {
+ return msg
+ } else if (typeof msg === 'function') {
+ return msg(values as never)
+ }
+ }
+ console.warn(`Missing key or locale for locale "${locale}" and key "${String(key)}" in all translations`)
+ return `{{${locale}:${String(key)}}}`
+ }
+
+ return translation
+}
\ No newline at end of file
diff --git a/src/icu.ts b/src/icu.ts
new file mode 100644
index 0000000..feb62e3
--- /dev/null
+++ b/src/icu.ts
@@ -0,0 +1,430 @@
+const escapeCharacter = "'"
+
+/////////////
+// Lexer
+/////////////
+
+export type ICUToken =
+ | { type: 'LBRACE' }
+ | { type: 'RBRACE' }
+ | { type: 'COMMA' }
+ | { type: 'HASHTAG' }
+ | { type: 'ESCAPE' }
+ | { type: 'WHITESPACE', value: string }
+ | { type: 'TEXT', value: string }
+
+/**
+ * ICU uses single quotes to quote literal text. This means:
+ * '' -> '
+ * '...anything...' -> literal anything (but two single quotes inside become one)
+ */
+function lex(input: string): ICUToken[] {
+ const tokens: ICUToken[] = []
+
+ function pushAppend(text: string, type: 'TEXT' | 'WHITESPACE') {
+ if (tokens.length > 0) {
+ const last = tokens[tokens.length - 1]
+ if (last.type === type) {
+ last.value += text
+ return
+ }
+ }
+ tokens.push({ type, value: text })
+ }
+
+ for (let index = 0; index < input.length; index++) {
+ const character = input[index]
+ switch (character) {
+ case '{':
+ tokens.push({ type: 'LBRACE' })
+ break
+ case '}':
+ tokens.push({ type: 'RBRACE' })
+ break
+ case '#':
+ tokens.push({ type: 'HASHTAG' })
+ break
+ case ',':
+ tokens.push({ type: 'COMMA' })
+ break
+ case escapeCharacter:
+ tokens.push({ type: 'ESCAPE' })
+ break
+ case ' ':
+ pushAppend(character, 'WHITESPACE')
+ break
+ default:
+ pushAppend(character, 'TEXT')
+ break
+ }
+ }
+
+ return tokens
+}
+
+/////////////
+// Parser -> AST
+/////////////
+
+const replaceOperations = ['plural', 'select'] as const
+type ReplaceOperation = typeof replaceOperations[number]
+
+export type ICUASTNode =
+ | { type: 'Node', parts: ICUASTNode[] }
+ | { type: 'Text', value: string }
+ | { type: 'NumberField' }
+ | { type: 'SimpleReplace', variableName: string } // {name}
+ | { type: 'OptionReplace', variableName: string, operatorName: ReplaceOperation, options: Record } // {var, select, key{msg} ...}
+
+type ParserState = { name: 'escape' } |
+ { name: 'normal' } |
+ {
+ name: 'replaceFunction',
+ expect: ReplaceExpectState,
+ variableName: string,
+ subtree: ICUASTNode[],
+ operatorName?: ReplaceOperation,
+ optionName: string,
+ options: Record,
+ }
+
+type ReplaceExpectState =
+ 'variableName'
+ | 'variableNameCommaOrSimpleReplaceClose'
+ | 'operatorName'
+ | 'operatorNameComma'
+ | 'optionNameOrReplaceClose'
+ | 'optionOpen'
+ | 'optionContentOrClose'
+
+type ParserContext = {
+ state: ParserState[],
+ last?: ICUToken,
+}
+
+function parse(tokens: ICUToken[]): ICUASTNode {
+ const result: ICUASTNode[] = []
+
+ const context: ParserContext = {
+ state: [{ name: 'normal' }],
+ }
+
+ function getState() {
+ const state = context.state[context.state.length - 1]
+ if (!state) {
+ throw new Error('ICU Parser: Reached invalid state')
+ }
+ return state
+ }
+
+ function getStateName() {
+ return getState().name
+ }
+
+ function pushText(text: string, target: ICUASTNode[] = result) {
+ if (target.length > 0) {
+ const last = target[target.length - 1]
+ if (last.type === 'Text') {
+ last.value += text
+ return
+ }
+ }
+ target.push({ type: 'Text', value: text })
+ }
+
+ function inNormal(token: ICUToken) {
+ switch (token.type) {
+ case 'RBRACE':
+ throw Error('ICU Parser: Read an unescaped "}" before reading a "{"')
+ case 'LBRACE':
+ context.state.push({
+ name: 'replaceFunction',
+ expect: 'variableName',
+ variableName: '',
+ optionName: '',
+ options: {},
+ subtree: [],
+ })
+ break
+ case 'ESCAPE':
+ context.state.push({ name: 'escape' })
+ break
+ case 'COMMA':
+ pushText(',')
+ break
+ case 'HASHTAG':
+ pushText('#')
+ break
+ case 'TEXT':
+ pushText(token.value)
+ break
+ case 'WHITESPACE':
+ pushText(token.value)
+ break
+ }
+ }
+
+ function inEscape(token: ICUToken) {
+ const prevState = context.state[context.state.length - 1]
+ let pushFunction: (value: string) => void = pushText
+ if (prevState && prevState.name === 'replaceFunction' && prevState.expect === 'operatorName') {
+ pushFunction = (value: string) => pushText(value, prevState.subtree)
+ }
+
+ switch (token.type) {
+ case 'ESCAPE':
+ if (context.last.type === 'ESCAPE') {
+ pushFunction(escapeCharacter)
+ }
+ context.state.pop()
+ break
+ case 'COMMA':
+ pushFunction(',')
+ break
+ case 'HASHTAG':
+ pushFunction('#')
+ break
+ case 'LBRACE':
+ pushFunction('{')
+ break
+ case 'RBRACE':
+ pushFunction('}')
+ break
+ default:
+ pushFunction(token.value)
+ }
+ }
+
+ // Closing and opening brackets are already removed
+ function inReplaceFunction(token: ICUToken) {
+ const state = getState()
+ if (state.name !== 'replaceFunction') {
+ throw Error(`ICU Parser: Invalid State of Parser. Contact Package developer.`)
+ }
+ switch (token.type) {
+ case 'ESCAPE':
+ if (state.expect !== 'optionContentOrClose') {
+ throw Error(`ICU Parser: Invalid Escape character "'". Escape characters are only valid outside of replacement functions or in the option content.`)
+ }
+ context.state.push({ name: 'escape' })
+ break
+ case 'LBRACE':
+ if (state.expect === 'optionOpen') {
+ state.expect = 'optionContentOrClose'
+ } else if (state.expect === 'optionContentOrClose') {
+ context.state.push({
+ name: 'replaceFunction',
+ expect: 'variableName',
+ variableName: '',
+ optionName: '',
+ options: {},
+ subtree: []
+ })
+ } else {
+ throw Error(`ICU Parser: Invalid placement of "{" in replacement function.`)
+ }
+ break
+ case 'RBRACE':
+ if (state.expect === 'variableNameCommaOrSimpleReplaceClose') {
+ context.state.pop()
+ const prevState = getState()
+ const node: ICUASTNode = {
+ type: 'SimpleReplace',
+ variableName: state.variableName
+ }
+ if (prevState.name === 'replaceFunction') {
+ prevState.subtree.push(node)
+ } else {
+ result.push(node)
+ }
+ } else if (state.expect === 'optionContentOrClose') {
+ const subTree = state.subtree
+ state.options[state.optionName] = subTree.length === 1 ? subTree[0] : { type: 'Node', parts: subTree }
+ state.expect = 'optionNameOrReplaceClose'
+ state.subtree = []
+ } else if (state.expect === 'optionNameOrReplaceClose') {
+ context.state.pop()
+ const prevState = getState()
+ const node: ICUASTNode = {
+ type: 'OptionReplace',
+ variableName: state.variableName,
+ operatorName: state.operatorName,
+ options: state.options,
+ }
+ if (prevState.name === 'replaceFunction') {
+ prevState.subtree.push(node)
+ } else {
+ result.push(node)
+ }
+ } else {
+ throw Error(`ICU Parser: Invalid placement of "}" in replacement function.`)
+ }
+ break
+ case 'HASHTAG': {
+ if (state.expect === 'optionContentOrClose') {
+ if (state.operatorName === 'plural') {
+ state.subtree.push({ type: 'NumberField' })
+ } else {
+ pushText('#', state.subtree)
+ }
+ } else {
+ throw Error(`ICU Parser: Invalid placement of "#". "#" are only valid outside of replacement functions or in the option content.`)
+ }
+ break
+ }
+ case 'COMMA':
+ if (state.expect === 'operatorNameComma') {
+ state.expect = 'optionNameOrReplaceClose'
+ } else if (state.expect === 'variableNameCommaOrSimpleReplaceClose') {
+ state.expect = 'operatorName'
+ } else if (state.expect === 'optionContentOrClose') {
+ pushText(',', state.subtree)
+ } else {
+ console.log(context.state[context.state.length - 1])
+ throw Error(`ICU Parser: Invalid placement of "," in replacement function.`)
+ }
+ break
+ case 'WHITESPACE':
+ if (state.expect === 'optionContentOrClose') {
+ pushText(token.value, state.subtree)
+ }
+ break
+ case 'TEXT':
+ if (state.expect === 'variableName') {
+ state.variableName = token.value
+ state.expect = 'variableNameCommaOrSimpleReplaceClose'
+ } else if (state.expect === 'operatorName') {
+ if (replaceOperations.some(value => value === token.value)) {
+ state.operatorName = token.value as ReplaceOperation
+ } else {
+ throw Error(`ICU Parser: ${token.value} is an invalid replacement function operator. Allowed are ${replaceOperations.map(value => `"${value}"`).join(', ')}`)
+ }
+ state.expect = 'operatorNameComma'
+ } else if (state.expect === 'optionNameOrReplaceClose') {
+ state.optionName = token.value
+ state.expect = 'optionOpen'
+ } else if (state.expect === 'optionContentOrClose') {
+ pushText(token.value, state.subtree)
+ } else {
+ throw Error('ICU Parser: Invalid position of a Text block in a replacement function.')
+ }
+ break
+ }
+ }
+
+ for (let index = 0; index < tokens.length; index++) {
+ const token = tokens[index]
+ const state = getStateName()
+
+ if (state === 'normal') {
+ inNormal(token)
+ } else if (state === 'replaceFunction') {
+ inReplaceFunction(token)
+ } else if (state === 'escape') {
+ inEscape(token)
+ }
+ context.last = token
+ }
+
+ const state = getStateName()
+
+ if (state === 'replaceFunction') {
+ throw Error(`ICU Parse: Encountered unclosed "{"`)
+ } else if (state === 'escape') {
+ throw Error(`ICU Parse: Encountered unclosed escape "'"`)
+ }
+ return result.length !== 1 ? { type: 'Node', parts: result } : result[0]
+}
+
+/////////////
+// Compiler
+/////////////
+
+type CompilerContext = {
+ hashtagReplacer?: number,
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type ICUCompilerValues = Record
+
+function compile(node: ICUASTNode, values: ICUCompilerValues, context: CompilerContext = {}): string {
+ switch (node.type) {
+ case 'Node': {
+ return node.parts.map(p => compile(p, values, context)).join('')
+ }
+ case 'Text':
+ return node.value
+ case 'SimpleReplace': {
+ const name = node.variableName
+ if (values && values[name] !== undefined) return String(values[name])
+ console.warn(`ICU Compile: missing value for ${name}`)
+ return `{${name}}`
+ }
+ case 'OptionReplace': {
+ const name = node.variableName
+ const operation = node.operatorName
+ const val = values ? values[name] : undefined
+ switch (operation) {
+ case 'plural': {
+ const num = Number(val)
+ if (isNaN(num)) {
+ console.warn(`ICU Compile: plural expected numeric value for ${name}, got ${val}`)
+ return `{${name}}`
+ }
+ const pluralKey =
+ num === 0 ? '=0' :
+ num === 1 ? '=1' :
+ num === 2 ? '=2' :
+ num > 2 && num < 5 ? 'few' :
+ num >= 5 ? 'many' : 'other'
+
+ const chosen = node.options[pluralKey] ?? node.options['other']
+ if (!chosen) {
+ console.warn(`ICU Compile: plural for ${name} could not find key ${pluralKey} and no other`)
+ return `{${name}}`
+ }
+ return compile(chosen, values, { ...context, hashtagReplacer: num })
+ }
+ case 'select': {
+ if (val === undefined) {
+ console.warn(`ICU Compile: missing value for select ${name}`)
+ const other = node.options['other']
+ return other ? compile(other, values, context) : `{${name}}`
+ }
+ const chosen = node.options[String(val)] ?? node.options['other']
+ if (!chosen) {
+ console.warn(`ICU Compile: select ${name} chose undefined option "${val}" and no "other" provided`)
+ return `{${name}}`
+ }
+ return compile(chosen, values, context)
+ }
+ default: {
+ return `{${name}, ${operation}}`
+ }
+ }
+ }
+ case 'NumberField': {
+ if (context.hashtagReplacer !== undefined) {
+ return `${context.hashtagReplacer}`
+ } else {
+ return '{#}'
+ }
+ }
+ }
+}
+
+function interpret(message: string, values: ICUCompilerValues): string {
+ try {
+ return compile(parse(lex(message)), values)
+ } catch (e) {
+ console.error(`Failed to interpret message: ${message}`, e)
+ return message
+ }
+}
+
+export const ICUUtil = {
+ lex,
+ parse,
+ compile,
+ interpret
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..8f66b30
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,4 @@
+export * from './icu'
+export * from './combineTranslation'
+export * from './types'
+export * from './translationGeneration'
diff --git a/src/scripts/compile-arb.ts b/src/scripts/compile-arb.ts
new file mode 100644
index 0000000..0d2b4cb
--- /dev/null
+++ b/src/scripts/compile-arb.ts
@@ -0,0 +1,431 @@
+#!/usr/bin/env node
+import fs from 'fs'
+import path from 'path'
+import readline from 'readline'
+import type { ICUASTNode } from '@/src'
+import { ICUUtil } from '@/src'
+
+/* ------------------ types ------------------ */
+
+interface PlaceholderMeta {
+ type?: string,
+}
+
+interface ARBPlaceholders {
+ [key: string]: PlaceholderMeta,
+}
+
+interface ARBMeta {
+ placeholders?: ARBPlaceholders,
+}
+
+interface ARBFile {
+ [key: string]: string | ARBMeta,
+}
+
+interface FuncParam {
+ name: string,
+ typing: string,
+}
+
+type TranslationEntry =
+ { type: 'text', value: string }
+ | { type: 'func', params: FuncParam[], value: string }
+ | { type: 'nested', value: Record }
+
+/* ------------------ CLI args ------------------ */
+function parseArgs() {
+ const args = process.argv.slice(2)
+ const result: {
+ input?: string,
+ outputFile?: string,
+ force: boolean,
+ help: boolean,
+ } = {
+ force: false,
+ help: false
+ }
+
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i]
+
+ switch (arg) {
+ case '--in':
+ case '-i':
+ result.input = args[++i]
+ break
+
+ case '--out':
+ case '-o':
+ result.outputFile = args[++i]
+ break
+
+ case '--force':
+ case '-f':
+ result.force = true
+ break
+
+ case '--help':
+ case '-h':
+ result.help = true
+ break
+ }
+ }
+
+ return result
+}
+
+
+function printHelp() {
+ console.log(`
+Usage: i18n-compile [options]
+
+Options:
+ -i, --in Input directory containing .arb files
+ -o, --out Output file (e.g. ./i18n/translations.ts)
+ -f, --force Overwrite output without prompt
+ -h, --help Show this help message
+`)
+}
+
+const parsed = parseArgs()
+
+if (parsed.help) {
+ printHelp()
+ process.exit(0)
+}
+
+const inputDir =
+ parsed.input || path.resolve(process.cwd(), './locales')
+
+// Default output file if none given
+const OUTPUT_FILE =
+ parsed.outputFile ||
+ path.resolve(process.cwd(), './i18n/translations.ts')
+
+const force = parsed.force
+
+const outputDir = path.dirname(OUTPUT_FILE)
+
+/* ------------------ prompts ------------------ */
+
+function askQuestion(query: string): Promise {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout
+ })
+ return new Promise(resolve =>
+ rl.question(query, ans => {
+ rl.close()
+ resolve(ans)
+ }))
+}
+
+/* ------------------ I/O helpers ------------------ */
+
+if (!fs.existsSync(outputDir)) {
+ fs.mkdirSync(outputDir, { recursive: true })
+}
+
+const locales = new Set()
+
+/* ------------------ ARB reader ------------------ */
+
+function readARBDir(
+ dir: string,
+ prefix = ''
+): Record> {
+ const result: Record> = {}
+
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
+
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name)
+
+ if (entry.isDirectory()) {
+ const newPrefix = prefix ? `${prefix}.${entry.name}` : entry.name
+ const values = readARBDir(fullPath, newPrefix)
+ for (const locale of locales) {
+ Object.assign(result[locale], values[locale])
+ }
+ continue
+ }
+
+ if (!entry.isFile() || !entry.name.endsWith('.arb')) continue
+
+ const locale = path.basename(entry.name, '.arb')
+ locales.add(locale)
+
+ const raw = fs.readFileSync(fullPath, 'utf-8')
+ const content: ARBFile = JSON.parse(raw)
+
+ if (!result[locale]) result[locale] = {}
+
+ for (const [key, value] of Object.entries(content)) {
+ if (key.startsWith('@')) continue
+
+ const meta = content[`@${key}`] as ARBMeta | undefined
+ const flatKey = prefix ? `${prefix}.${key}` : key
+
+ let entryObj: TranslationEntry
+
+ if (meta?.placeholders) {
+ // ICU function
+ const params: FuncParam[] = Object.entries(meta.placeholders).map(
+ ([name, def]) => {
+ let typing = def.type
+ if (!typing) {
+ if (['count', 'amount', 'length', 'number'].includes(name)) {
+ typing = 'number'
+ } else if (['date', 'dateTime'].includes(name)) {
+ typing = 'Date'
+ } else {
+ typing = 'string'
+ }
+ }
+ return { name, typing }
+ }
+ )
+
+ entryObj = {
+ type: 'func',
+ params,
+ value: value as string,
+ }
+ } else {
+ entryObj = {
+ type: 'text',
+ value: value as string
+ }
+ }
+
+ result[locale][flatKey] = entryObj
+ }
+ }
+
+ return result
+}
+
+/* ------------------ code generator: values ------------------ */
+
+function escapeForTemplateJS(s: string): string {
+ return s.replace(/`/g, '\\`')
+}
+
+function compile(
+ node: ICUASTNode,
+ context: { numberParam?: string, inNode: boolean, indentLevel: number } = { indentLevel: 0, inNode: false }
+): string[] {
+ const lines: string[] = []
+ let currentLine = ''
+ const isTopLevel = context.indentLevel === 0
+
+ function indent(level: number = context.indentLevel) {
+ return ' '.repeat(level * 2)
+ }
+
+ function flushCurrent() {
+ if(currentLine) {
+ if(context.inNode) {
+ lines.push(currentLine)
+ } else {
+ const prefix = !isTopLevel ? indent() : '_out += '
+ const nextLine = `${prefix}\`${escapeForTemplateJS(currentLine)}\``
+ lines.push(nextLine)
+ }
+ }
+ currentLine = ''
+ }
+
+ switch (node.type) {
+ case 'Text':
+ currentLine += node.value
+ break
+ case 'NumberField':
+ if (context.numberParam) {
+ currentLine += `$\{${context.numberParam}}`
+ } else {
+ currentLine += `{${context.numberParam}}`
+ }
+ break
+ case 'SimpleReplace':
+ currentLine += `$\{${node.variableName}}`
+ break
+ case 'Node': {
+ for (const partNode of node.parts) {
+ const compiled = compile(partNode, { ...context, inNode: true })
+ if (partNode.type === 'OptionReplace' || partNode.type === 'Node') {
+ flushCurrent()
+ lines.push(...compiled)
+ } else {
+ currentLine += compiled[0]
+ }
+ }
+ break
+ }
+ case 'OptionReplace': {
+ flushCurrent()
+ lines.push(`${isTopLevel ? '_out += ' : ''}TranslationGen.resolveSelect(${node.variableName}, {`)
+
+ const entries = Object.entries(node.options)
+
+ for (const [key, entryNode] of entries) {
+ const numberParamUpdate = node.operatorName === 'plural' ? key : undefined
+ const expr = compile(entryNode, {
+ ...context,
+ numberParam: numberParamUpdate ?? context.numberParam,
+ indentLevel: context.indentLevel + 1,
+ inNode: false,
+ })
+ if(expr.length === 0 ) continue
+ lines.push(indent(context.indentLevel + 1) + `'${key}': ${expr[0].trimStart()}`, ...expr.slice(1))
+ lines[lines.length - 1] += ','
+ }
+
+ lines.push(indent() + `})`)
+ return lines
+ }
+ }
+ flushCurrent()
+ return lines
+}
+
+
+function generateCode(
+ obj: Record,
+ indentLevel = 1
+): string {
+ const indent = ' '.repeat(indentLevel)
+ const entries = Object.entries(obj).sort((a, b) =>
+ a[0].localeCompare(b[0]))
+
+ let str = ''
+
+ for (const [key, entry] of entries) {
+ const quotedKey = `'${key}'`
+ const isLast = entries[entries.length - 1][0] === key
+ const comma = isLast ? '' : ','
+
+ if (entry.type === 'func') {
+ const ast = ICUUtil.parse(ICUUtil.lex(entry.value))
+ let compiled = compile(ast)
+ if (compiled.filter(value => value.startsWith('_out +=')).length === 1) {
+ const first = compiled.findIndex(value => value.startsWith('_out +='))
+ compiled[first] = 'return ' + compiled[first].slice(8)
+ } else {
+ compiled = [
+ "let _out: string = ''",
+ ...compiled,
+ 'return _out',
+ ]
+ }
+ const functionLines: string[] = [
+ `({ ${entry.params.map(value => value.name).join(', ')} }): string => {`,
+ ...compiled.map(value => ` ${value}`),
+ '}',
+ ]
+ str += `${indent}${quotedKey}: ${functionLines.join(`\n${indent}`)}${comma}\n`
+ } else if (entry.type === 'text') {
+ str += `${indent}${quotedKey}: \`${escapeForTemplateJS(entry.value)}\`${comma}\n`
+ } else {
+ // nested object
+ str += `${indent}${quotedKey}: {\n`
+ str += generateCode(entry.value, indentLevel + 1)
+ str += `${indent}}${comma}\n`
+ }
+ }
+
+ return str
+}
+
+/* ------------------ code generator: type ------------------ */
+
+function generateType(
+ translationData: Record>
+): string {
+ const indent = ' '
+ const fullObject: Record = {}
+ const completedLocales: string[] = []
+
+ for (const locale of locales) {
+ const localizedEntries = Object.entries(
+ translationData[locale]
+ ).sort((a, b) => a[0].localeCompare(b[0]))
+
+ for (const [name, entry] of localizedEntries) {
+ if (!fullObject[name]) {
+ fullObject[name] = entry
+ continue
+ }
+
+ // type consistency is logged but not enforced
+ }
+ completedLocales.push(locale)
+ }
+
+ let str = ''
+
+ for (const [key, entry] of Object.entries(fullObject)) {
+ const quotedKey = `'${key}'`
+
+ if (entry.type === 'func') {
+ const params = entry.params
+ .map(p => `${p.name}: ${p.typing}`)
+ .join(', ')
+ str += `${indent}${quotedKey}: (values: { ${params} }) => string,\n`
+ } else if (entry.type === 'text') {
+ str += `${indent}${quotedKey}: string,\n`
+ }
+ }
+
+ return str
+}
+
+/* ------------------ main ------------------ */
+
+async function main(): Promise {
+ const translationData = readARBDir(inputDir)
+
+ let output = `// AUTO-GENERATED. DO NOT EDIT.\n`
+ output += `import type { Translation } from '@helpwave/internationalization'\n`
+ output += `import { TranslationGen } from '@helpwave/internationalization'\n\n`
+
+ output += '/* eslint-disable @stylistic/quote-props */\n'
+
+ output += `export const supportedLocales = [${[
+ ...locales.values()
+ ]
+ .map(v => `'${v}'`)
+ .join(', ')}] as const\n\n`
+
+ output += `export type SupportedLocale = typeof supportedLocales[number]\n\n`
+
+ const generatedTyping = generateType(translationData)
+ output += `export type GeneratedTranslationEntries = {\n${generatedTyping}}\n\n`
+
+ const value: Record = {}
+ for (const locale of locales) {
+ value[locale] = { type: 'nested', value: translationData[locale] }
+ }
+
+
+ const generatedTranslation = generateCode(value)
+ output += `export const generatedTranslations: Translation> = {\n${generatedTranslation}}\n\n`
+
+ if (fs.existsSync(OUTPUT_FILE) && !force) {
+ const answer = await askQuestion(
+ `File "${OUTPUT_FILE}" already exists. Overwrite? (y/N): `
+ )
+ if (!['y', 'yes'].includes(answer.trim().toLowerCase())) {
+ console.info('Aborted.')
+ return
+ }
+ }
+
+ fs.writeFileSync(OUTPUT_FILE, output)
+ console.info(`✓ Translations compiled to ${OUTPUT_FILE}`)
+ console.info(`Input folder: ${inputDir}`)
+ console.info(`Output folder: ${outputDir}`)
+}
+
+main()
diff --git a/src/translationGeneration.ts b/src/translationGeneration.ts
new file mode 100644
index 0000000..9e2d1e6
--- /dev/null
+++ b/src/translationGeneration.ts
@@ -0,0 +1,25 @@
+function resolveSelect(
+ value: string | number | undefined | null,
+ options: Record string)>
+): string {
+ const v = value == null ? 'other' : String(value)
+ const handler = options[v] ?? options['other']
+
+ if (handler == null) return ''
+ return typeof handler === 'function' ? handler() : handler
+}
+
+function resolvePlural(
+ value: number,
+ options: Record string)>
+): string {
+ const v = String(value)
+ const handler = options[v] ?? options['other']
+ if (handler == null) return ''
+ return typeof handler === 'function' ? handler() : handler
+}
+
+export const TranslationGen = {
+ resolveSelect,
+ resolvePlural,
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..9da1f22
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,7 @@
+export type TranslationEntry = string | ((values: object) => string)
+
+export type TranslationEntries = Record
+
+export type Translation = Record
+
+export type PartialTranslation = Partial>>
\ No newline at end of file
diff --git a/tests/compiler.test.ts b/tests/compiler.test.ts
new file mode 100644
index 0000000..8dd06c9
--- /dev/null
+++ b/tests/compiler.test.ts
@@ -0,0 +1,218 @@
+import type { ICUASTNode, ICUCompilerValues } from '../src'
+import { ICUUtil } from '../src'
+
+type ExampleValues = {
+ name: string,
+ values: ICUCompilerValues,
+ input: ICUASTNode,
+ result: string,
+}
+
+const examples: ExampleValues[] = [
+ {
+ name: 'Simple Replace',
+ values: { name: 'Alice' },
+ input: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Hello ' },
+ { type: 'SimpleReplace', variableName: 'name' }
+ ]
+ },
+ result: 'Hello Alice',
+ },
+ {
+ name: 'Plural with number insertion',
+ values: { count: 1 },
+ input: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'You have ' },
+ {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=1': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' apple' }
+ ]
+ },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' apples' }
+ ]
+ }
+ }
+ }
+ ]
+ },
+ result: 'You have 1 apple',
+ },
+ {
+ name: 'Select with nested replacement',
+ values: { gender: 'female', name: 'Lee' },
+ input: {
+ type: 'OptionReplace',
+ operatorName: 'select',
+ variableName: 'gender',
+ options: {
+ male: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Hello Mr. ' },
+ { type: 'SimpleReplace', variableName: 'name' }
+ ]
+ },
+ female: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Hello Ms. ' },
+ { type: 'SimpleReplace', variableName: 'name' }
+ ]
+ },
+ other: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Hello ' },
+ { type: 'SimpleReplace', variableName: 'name' }
+ ]
+ }
+ }
+ },
+ result: 'Hello Ms. Lee',
+ },
+ {
+ name: 'Plural and Select in succession',
+ values: { count: 0, gender: 'male' },
+ input: {
+ type: 'Node',
+ parts: [
+ {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=0': { type: 'Text', value: 'no items' },
+ '=1': { type: 'Text', value: 'one item' },
+ 'other': {
+ type: 'Node', parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' items' }
+ ]
+ }
+ }
+ },
+ { type: 'Text', value: ' and ' },
+ {
+ type: 'OptionReplace',
+ operatorName: 'select',
+ variableName: 'gender',
+ options: {
+ male: { type: 'Text', value: 'sir' },
+ other: { type: 'Text', value: 'friend' }
+ }
+ }
+ ]
+ },
+ result: 'no items and sir',
+ },
+ {
+ name: 'Plural nested in Select',
+ values: { userType: 'member', count: 3 },
+ input: {
+ type: 'OptionReplace',
+ operatorName: 'select',
+ variableName: 'userType',
+ options: {
+ admin: {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=1': { type: 'Text', value: 'Admin, 1 message' },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Admin, ' },
+ { type: 'NumberField' },
+ { type: 'Text', value: ' messages' }
+ ]
+ }
+ }
+ },
+ member: {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=1': { type: 'Text', value: 'Member, 1 message' },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Member, ' },
+ { type: 'NumberField' },
+ { type: 'Text', value: ' messages' }
+ ]
+ }
+ }
+ },
+ other: { type: 'Text', value: 'Guest' }
+ }
+ },
+ result:
+ 'Member, 3 messages',
+ },
+ {
+ name: 'Replace, Escape and Plural',
+ values: { count: 2 },
+ input: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Today is {special} and you have ' },
+ {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=1': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' cat' }
+ ]
+ },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' cats' }
+ ]
+ }
+ }
+ }
+ ]
+ },
+ result: 'Today is {special} and you have 2 cats',
+ },
+ {
+ name: 'Escape sequence',
+ values: {},
+ input: { type: 'Text', value: "''' {}" },
+ result: "''' {}",
+ }
+]
+
+
+describe('ICU Compiler', () => {
+ for (const example of examples) {
+ test(`${example.name}`, () => {
+ const result = ICUUtil.compile(example.input, example.values)
+ expect(result).toEqual(example.result)
+ })
+ }
+})
diff --git a/tests/interpreter.test.ts b/tests/interpreter.test.ts
new file mode 100644
index 0000000..6372ec8
--- /dev/null
+++ b/tests/interpreter.test.ts
@@ -0,0 +1,63 @@
+import type { ICUCompilerValues } from '../src'
+import { ICUUtil } from '../src'
+
+type TestValues = {
+ name: string,
+ message: string,
+ values: ICUCompilerValues,
+ result: string,
+}
+
+const tests: TestValues[] = [
+ {
+ name: 'Simple Replace',
+ message: 'Hello {name}',
+ values: { name: 'Alice' },
+ result: 'Hello Alice',
+ },
+ {
+ name: 'Plural with number insertion',
+ message: 'You have {count, plural, =1{# apple} other{# apples}}',
+ values: { count: 1 },
+ result: 'You have 1 apple',
+ },
+ {
+ name: 'Select with nested replacement',
+ message: '{gender, select, male{Hello Mr. {name}} female{Hello Ms. {name}} other{Hello {name}}}',
+ values: { gender: 'female', name: 'Lee' },
+ result: 'Hello Ms. Lee',
+ },
+ {
+ name: 'Plural and Select in succession',
+ message: '{count, plural, =0{no items} =1{one item} other{# items}} and {gender, select, male{sir} other{friend}}',
+ values: { count: 0, gender: 'male' },
+ result: 'no items and sir',
+ },
+ {
+ name: 'Plural nested in Select',
+ message: '{userType, select, admin{{count, plural, =1{Admin, 1 message} other{Admin, # messages}}} member{{count, plural, =1{Member, 1 message} other{Member, # messages}}} other{Guest}}',
+ values: { userType: 'member', count: 3 },
+ result: 'Member, 3 messages',
+ },
+ {
+ name: 'Replace, Escape and Plural',
+ message: "Today is '{'special'}' and you have {count, plural, =1{# cat} other{# cats}}",
+ values: { count: 2 },
+ result: 'Today is {special} and you have 2 cats',
+ },
+ {
+ name: 'Escape sequence',
+ message: "'''''' '{}'",
+ values: {},
+ result: "''' {}",
+ }
+]
+
+describe('ICU Interpreter', () => {
+ for (const example of tests) {
+ test(`${example.name}: ${example.message}`, () => {
+ const result = ICUUtil.interpret(example.message, example.values)
+ expect(result).toEqual(example.result)
+ })
+ }
+})
diff --git a/tests/lexer.test.ts b/tests/lexer.test.ts
new file mode 100644
index 0000000..d7ab3a0
--- /dev/null
+++ b/tests/lexer.test.ts
@@ -0,0 +1,302 @@
+import type { ICUToken } from '../src'
+import { ICUUtil } from '../src'
+
+type ExampleValues = {
+ name: string,
+ message: string,
+ result: ICUToken[],
+}
+
+const examples: ExampleValues[] = [
+ {
+ name: 'Simple Replace',
+ message: 'Hello {name}',
+ result: [
+ { type: 'TEXT', value: 'Hello' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'name' },
+ { type: 'RBRACE' }
+ ],
+ },
+ {
+ name: 'Plural with number insertion',
+ message: 'You have {count, plural, =1{# apple} other{# apples}}',
+ result: [
+ { type: 'TEXT', value: 'You' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'have' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'count' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'plural' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=1' },
+ { type: 'LBRACE' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'apple' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'apples' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ },
+ {
+ name: 'Select with nested replacement',
+ message: '{gender, select, male{Hello Mr. {name}} female{Hello Ms. {name}} other{Hello {name}}}',
+ result: [
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'gender' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'select' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'male' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Hello' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'Mr.' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'name' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'female' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Hello' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'Ms.' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'name' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Hello' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'name' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ },
+ {
+ name: 'Plural and Select in succession',
+ message: '{count, plural, =0{no items} =1{one item} other{# items}} and {gender, select, male{sir} other{friend}}',
+ result: [
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'count' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'plural' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=0' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'no' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'items' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=1' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'one' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'item' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'items' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'and' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'gender' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'select' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'male' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'sir' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'friend' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ },
+ {
+ name: 'Plural nested in Select',
+ message: '{userType, select, admin{{count, plural, =1{Admin, 1 message} other{Admin, # messages}}} member{{count, plural, =1{Member, 1 message} other{Member, # messages}}} other{Guest}}',
+ result: [
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'userType' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'select' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'admin' },
+ { type: 'LBRACE' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'count' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'plural' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=1' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Admin' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '1' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'message' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Admin' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'messages' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'member' },
+ { type: 'LBRACE' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'count' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'plural' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=1' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Member' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '1' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'message' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Member' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'messages' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Guest' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ },
+ {
+ name: 'Replace, Escape and Plural',
+ message: "Today is '{'special'}' and you have {count, plural, =1{# cat} other{# cats}}",
+ result: [
+ { type: 'TEXT', value: 'Today' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'is' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'ESCAPE' },
+ { type: 'LBRACE' },
+ { type: 'ESCAPE' },
+ { type: 'TEXT', value: 'special' },
+ { type: 'ESCAPE' },
+ { type: 'RBRACE' },
+ { type: 'ESCAPE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'and' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'you' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'have' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'count' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'plural' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=1' },
+ { type: 'LBRACE' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'cat' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'cats' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ },
+ {
+ name: 'Escape sequence',
+ message: "'''''' '{}'",
+ result: [
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'ESCAPE' },
+ { type: 'LBRACE' },
+ { type: 'RBRACE' },
+ { type: 'ESCAPE' },
+ ],
+ }
+]
+
+describe('ICU Lexer', () => {
+ for (const example of examples) {
+ test(`${example.name}: ${example.message}`, () => {
+ const result = ICUUtil.lex(example.message)
+ expect(result).toEqual(example.result)
+ })
+ }
+})
\ No newline at end of file
diff --git a/tests/parser.test.ts b/tests/parser.test.ts
new file mode 100644
index 0000000..83f7c5c
--- /dev/null
+++ b/tests/parser.test.ts
@@ -0,0 +1,459 @@
+import type { ICUASTNode, ICUToken } from '../src'
+import { ICUUtil } from '../src'
+
+type ExampleValues = {
+ name: string,
+ input: ICUToken[],
+ result: ICUASTNode,
+}
+
+const examples: ExampleValues[] = [
+ {
+ name: 'Simple Replace',
+ input: [
+ { type: 'TEXT', value: 'Hello' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'name' },
+ { type: 'RBRACE' }
+ ],
+ result: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Hello ' },
+ { type: 'SimpleReplace', variableName: 'name' }
+ ]
+ },
+ },
+ {
+ name: 'Plural with number insertion',
+ input: [
+ { type: 'TEXT', value: 'You' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'have' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'count' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'plural' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=1' },
+ { type: 'LBRACE' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'apple' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'apples' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ result: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'You have ' },
+ {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=1': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' apple' }
+ ]
+ },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' apples' }
+ ]
+ }
+ }
+ }
+ ]
+ },
+ },
+ {
+ name: 'Select with nested replacement',
+ input: [
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'gender' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'select' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'male' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Hello' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'Mr.' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'name' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'female' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Hello' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'Ms.' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'name' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Hello' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'name' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ result: {
+ type: 'OptionReplace',
+ operatorName: 'select',
+ variableName: 'gender',
+ options: {
+ male: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Hello Mr. ' },
+ { type: 'SimpleReplace', variableName: 'name' }
+ ]
+ },
+ female: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Hello Ms. ' },
+ { type: 'SimpleReplace', variableName: 'name' }
+ ]
+ },
+ other: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Hello ' },
+ { type: 'SimpleReplace', variableName: 'name' }
+ ]
+ }
+ }
+ },
+ },
+ {
+ name: 'Plural and Select in succession',
+ input: [
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'count' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'plural' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=0' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'no' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'items' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=1' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'one' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'item' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'items' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'and' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'gender' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'select' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'male' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'sir' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'friend' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ result: {
+ type: 'Node',
+ parts: [
+ {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=0': { type: 'Text', value: 'no items' },
+ '=1': { type: 'Text', value: 'one item' },
+ 'other': {
+ type: 'Node', parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' items' }
+ ]
+ }
+ }
+ },
+ { type: 'Text', value: ' and ' },
+ {
+ type: 'OptionReplace',
+ operatorName: 'select',
+ variableName: 'gender',
+ options: {
+ male: { type: 'Text', value: 'sir' },
+ other: { type: 'Text', value: 'friend' }
+ }
+ }
+ ]
+ },
+ },
+ {
+ name: 'Plural nested in Select',
+ input: [
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'userType' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'select' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'admin' },
+ { type: 'LBRACE' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'count' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'plural' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=1' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Admin' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '1' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'message' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Admin' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'messages' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'member' },
+ { type: 'LBRACE' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'count' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'plural' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=1' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Member' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '1' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'message' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Member' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'messages' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'Guest' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ result: {
+ type: 'OptionReplace',
+ operatorName: 'select',
+ variableName: 'userType',
+ options: {
+ admin: {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=1': { type: 'Text', value: 'Admin, 1 message' },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Admin, ' },
+ { type: 'NumberField' },
+ { type: 'Text', value: ' messages' }
+ ]
+ }
+ }
+ },
+ member: {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=1': { type: 'Text', value: 'Member, 1 message' },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Member, ' },
+ { type: 'NumberField' },
+ { type: 'Text', value: ' messages' }
+ ]
+ }
+ }
+ },
+ other: { type: 'Text', value: 'Guest' }
+ }
+ },
+ },
+ {
+ name: 'Replace, Escape and Plural',
+ input: [
+ { type: 'TEXT', value: 'Today' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'is' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'ESCAPE' },
+ { type: 'LBRACE' },
+ { type: 'ESCAPE' },
+ { type: 'TEXT', value: 'special' },
+ { type: 'ESCAPE' },
+ { type: 'RBRACE' },
+ { type: 'ESCAPE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'and' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'you' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'have' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: 'count' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'plural' },
+ { type: 'COMMA' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '=1' },
+ { type: 'LBRACE' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'cat' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'HASHTAG' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'cats' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ result: {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Today is {special} and you have ' },
+ {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=1': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' cat' }
+ ]
+ },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' cats' }
+ ]
+ }
+ }
+ }
+ ]
+ },
+ },
+ {
+ name: 'Escape sequence',
+ input: [
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'ESCAPE' },
+ { type: 'LBRACE' },
+ { type: 'RBRACE' },
+ { type: 'ESCAPE' },
+ ],
+ result: {
+ type: 'Text',
+ value: "''' {}",
+ },
+ }
+]
+
+describe('ICU Parser', () => {
+ for (const example of examples) {
+ test(`${example.name}:`, () => {
+ const result = ICUUtil.parse(example.input)
+ console.log(result)
+ expect(result).toEqual(example.result)
+ })
+ }
+})
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..09f1b7c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2019",
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "outDir": "dist",
+ "rootDir": "src",
+ "declaration": true,
+ "declarationDir": "dist",
+ "jsx": "react-jsx",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"]
+ },
+ },
+ "include": [
+ "src",
+ ],
+}
diff --git a/tsup.config.ts b/tsup.config.ts
new file mode 100644
index 0000000..9cf1cba
--- /dev/null
+++ b/tsup.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'tsup'
+
+export default defineConfig({
+ entry: ['src/**/*.{ts,tsx}'],
+ format: ['esm', 'cjs'],
+ dts: true,
+ sourcemap: true,
+ outDir: 'dist',
+ clean: true,
+ splitting: false,
+ minify: false,
+ target: 'es2022',
+})