(
+ 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..2486c51
--- /dev/null
+++ b/src/icu.ts
@@ -0,0 +1,389 @@
+const escapeCharacter = "'"
+
+/////////////
+// Lexer
+/////////////
+
+// TODO treat # as a special case
+// TODO consider ' the escape character as a special case
+export type ICUToken =
+ | { type: 'LBRACE' }
+ | { type: 'RBRACE' }
+ | { type: 'COMMA' }
+ | { type: 'WHITESPACE', value: string }
+ | { type: 'TEXT', value: string }
+
+type LexerContext = {
+ buffer: string,
+ state: 'whitespace' | 'escape' | 'normal',
+ lastWasEscape: boolean,
+}
+
+/**
+ * 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[] = []
+
+ const context: LexerContext = {
+ buffer: '',
+ state: 'normal',
+ lastWasEscape: false,
+ }
+
+ function resetContext() {
+ context.buffer = ''
+ context.state = 'normal'
+ context.lastWasEscape = false
+ }
+
+ function pushText(text: string) {
+ if (tokens.length > 0) {
+ const last = tokens[tokens.length - 1]
+ if (last.type === 'TEXT') {
+ last.value += text
+ } else {
+ tokens.push({ type: 'TEXT', value: text })
+ }
+ } else {
+ tokens.push({ type: 'TEXT', value: text })
+ }
+ }
+
+ function inNormal(character: string) {
+ switch (character) {
+ case '{':
+ tokens.push({ type: 'LBRACE' })
+ break
+ case '}':
+ tokens.push({ type: 'RBRACE' })
+ break
+ case ',':
+ tokens.push({ type: 'COMMA' })
+ break
+ case ' ':
+ context.state = 'whitespace'
+ context.buffer += character
+ break
+ case escapeCharacter:
+ context.lastWasEscape = true
+ context.state = 'escape'
+ break
+ default:
+ pushText(character)
+ break
+ }
+ }
+
+ function inEscape(character: string) {
+ switch (character) {
+ case escapeCharacter: {
+ const text = context.lastWasEscape ? escapeCharacter : context.buffer
+ pushText(text)
+ resetContext()
+ break
+ }
+ default:
+ context.buffer += character
+ context.lastWasEscape = false
+ break
+ }
+ }
+
+ function inWhitespace(character: string) {
+ switch (character) {
+ case ' ':
+ context.buffer += character
+ break
+ default:
+ tokens.push({ type: 'WHITESPACE', value: context.buffer })
+ resetContext()
+ inNormal(character)
+ break
+ }
+ }
+
+ for (let index = 0; index < input.length; index++) {
+ const character = input[index]
+ if (context.state === 'escape') {
+ inEscape(character)
+ } else if (context.state === 'whitespace') {
+ inWhitespace(character)
+ } else {
+ inNormal(character)
+ }
+ }
+
+ // Handle final states
+ if (context.state === 'whitespace') {
+ tokens.push({ type: 'WHITESPACE', value: context.buffer })
+ } else if (context.state === 'escape') {
+ // The escape might not be closed here (should be solved in parser)
+ pushText(context.buffer)
+ }
+
+ return tokens
+}
+
+/////////////
+// Parser -> AST
+/////////////
+
+export type ICUASTNode =
+ | { type: 'Node', parts: ICUASTNode[] }
+ | { type: 'Text', value: string }
+ | { type: 'SimpleReplace', variableName: string } // {name}
+ | { type: 'OptionReplace', operatorName: string, variableName: string, options: Record } // {var, select, key{msg} ...}
+
+type ParserContext = {
+ subTree: ICUToken[],
+ openBrackets: number,
+}
+
+type ParserReplaceContext = {
+ subTree: ICUToken[],
+ openBrackets: number,
+ variableName?: string,
+ variableComma: boolean,
+ operatorName?: string,
+ operatorComma: boolean,
+ optionName?: string,
+ options: Record,
+}
+
+function parse(tokens: ICUToken[]): ICUASTNode {
+ const result: ICUASTNode[] = []
+
+ // Closing and opening brackets are already removed
+ function parseReplace(tokens: ICUToken[]): ICUASTNode {
+ if (tokens.length === 0) {
+ return { type: 'Text', value: '' }
+ } else if (
+ tokens.every(value => value.type === 'TEXT' || value.type === 'WHITESPACE') &&
+ tokens.filter(value => value.type === 'TEXT').length === 1
+ ) {
+ return {
+ type: 'SimpleReplace',
+ variableName: tokens.filter(value => value.type === 'TEXT')[0].value
+ }
+ }
+
+ const context: ParserReplaceContext = {
+ subTree: [],
+ openBrackets: 0,
+ options: {},
+ variableComma: false,
+ operatorComma: false,
+ }
+ for (let index = 0; index < tokens.length; index++) {
+ const token = tokens[index]
+ switch (token.type) {
+ case 'TEXT': {
+ if (context.openBrackets > 0) {
+ context.subTree.push(token)
+ } else if (!context.variableName) {
+ context.variableName = token.value
+ } else if (!context.operatorName && context.variableComma) {
+ context.operatorName = token.value
+ } else if (!context.optionName && context.operatorComma) {
+ context.optionName = token.value
+ } else {
+ throw Error(`ICU Parse: Encountered unexpected ${token.type} token`)
+ }
+ break
+ }
+ case 'COMMA': {
+ if (context.openBrackets > 0) {
+ context.subTree.push(token)
+ } else if (context.operatorName && !context.operatorComma) {
+ context.operatorComma = true
+ } else if (context.variableName && !context.variableComma) {
+ context.variableComma = true
+ } else {
+ throw Error(`ICU Parse: Encountered unexpected ${token.type} token`)
+ }
+ break
+ }
+ case 'WHITESPACE': {
+ if (context.openBrackets > 0) {
+ context.subTree.push(token)
+ }
+ break
+ }
+ case 'LBRACE': {
+ if (context.optionName) {
+ if (context.openBrackets > 0) {
+ context.subTree.push(token)
+ }
+ context.openBrackets++
+ } else {
+ throw Error(`ICU Parse: Encountered unexpected ${token.type} token`)
+ }
+ break
+ }
+ case 'RBRACE': {
+ if (context.optionName) {
+ context.openBrackets--
+ if (context.openBrackets < 0) {
+ throw new Error('ICU Parse: Mismatching amount of closing and opening brackets')
+ }
+ if (context.openBrackets === 0) {
+ context.options[context.optionName] = parse(context.subTree)
+ context.subTree = []
+ context.optionName = undefined
+ } else {
+ context.subTree.push(token)
+ }
+ } else {
+ throw Error(`ICU Parse: Encountered unexpected ${token.type} token`)
+ }
+ break
+ }
+ }
+ }
+ if (context.openBrackets > 0) {
+ throw new Error('ICU Parse: Mismatching amount of closing and opening brackets')
+ }
+ if (!context.operatorName && !context.variableName && Object.keys(context.options).length === 0) {
+ throw new Error('ICU Parse: Not a valid OptionReplace')
+ }
+ return {
+ type: 'OptionReplace',
+ operatorName: context.operatorName,
+ variableName: context.variableName,
+ options: context.options,
+ }
+ }
+
+ const context: ParserContext = {
+ openBrackets: 0,
+ subTree: []
+ }
+ for (let index = 0; index < tokens.length; index++) {
+ const token = tokens[index]
+ if (token.type === 'TEXT' || token.type === 'WHITESPACE' || token.type === 'COMMA') {
+ if (context.openBrackets > 0) {
+ context.subTree.push(token)
+ } else {
+ let text = ''
+ if (token.type === 'TEXT' || token.type === 'WHITESPACE') {
+ text += token.value
+ } else if (token.type === 'COMMA') {
+ text += ','
+ }
+ if (result.length > 0 && result[result.length - 1].type === 'Text') {
+ (result[result.length - 1] as { type: 'Text', value: string }).value += text
+ } else {
+ result.push({ type: 'Text', value: text })
+ }
+ }
+ } else if (token.type === 'RBRACE') {
+ context.openBrackets--
+ if (context.openBrackets < 0) {
+ throw Error(`ICU Parse: Encountered "}" without a prior "{"`)
+ } else if (context.openBrackets === 0) {
+ result.push(parseReplace(context.subTree))
+ context.subTree = []
+ } else if (context.openBrackets > 0) {
+ context.subTree.push(token)
+ }
+ } else if (token.type === 'LBRACE') {
+ if (context.openBrackets > 0) {
+ context.subTree.push(token)
+ }
+ context.openBrackets++
+ }
+ }
+ if (context.openBrackets > 0) {
+ throw Error(`ICU Parse: Encountered unclosed "{"`)
+ }
+ return result.length !== 1 ? { type: 'Node', parts: result } : result[0]
+}
+
+/////////////
+// Compiler
+/////////////
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type ICUCompilerValues = Record
+
+function compile(node: ICUASTNode, values: ICUCompilerValues): string {
+ switch (node.type) {
+ case 'Node': {
+ return node.parts.map(p => compile(p, values)).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}}`
+ }
+ const result = compile(chosen, values)
+ return result.replace(/#/g, String(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) : `{${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)
+ }
+ default: {
+ return `{${name}, ${operation}}`
+ }
+ }
+ }
+ default: {
+ 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..1fdc6bd
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,3 @@
+export * from "./icu"
+export * from "./combineTranslation"
+export * from "./types"
\ No newline at end of file
diff --git a/src/scripts/compile-arb.ts b/src/scripts/compile-arb.ts
new file mode 100644
index 0000000..29d1978
--- /dev/null
+++ b/src/scripts/compile-arb.ts
@@ -0,0 +1,337 @@
+#!/usr/bin/env node
+import fs from 'fs'
+import path from 'path'
+import readline from 'readline'
+
+/* ------------------ 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
+}
+
+interface TextEntry {
+ type: 'text'
+ value: string
+}
+
+interface FuncEntry {
+ type: 'func'
+ params: FuncParam[]
+ value: string
+}
+
+type TranslationEntry = TextEntry | FuncEntry | 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 })
+}
+
+function toSingleQuote(str: any): string {
+ if (typeof str !== 'string') return str
+ return `'${str.replace(/'/g, "\\'")}'`
+}
+
+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
+ const entryObj: any = {}
+
+ 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'
+ entryObj.params = params
+ entryObj.value = `(values): string => ICUUtil.interpret(${toSingleQuote(
+ value
+ )}, values)`
+ } else {
+ // plain text
+ entryObj.type = 'text'
+ entryObj.value = value as string
+ }
+
+ result[locale][flatKey] = entryObj
+ }
+ }
+
+ return result
+}
+
+/* ------------------ code generator: values ------------------ */
+
+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 as FuncEntry).type === 'func') {
+ str += `${indent}${quotedKey}: ${(entry as FuncEntry).value}${comma}\n`
+ } else if ((entry as TextEntry).type === 'text') {
+ str += `${indent}${quotedKey}: ${toSingleQuote(
+ (entry as TextEntry).value
+ )}${comma}\n`
+ } else {
+ // nested object
+ str += `${indent}${quotedKey}: {\n`
+ str += generateCode(entry as any, 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 as FuncEntry).type === 'func') {
+ const params = (entry as FuncEntry).params
+ .map(p => `${p.name}: ${p.typing}`)
+ .join(', ')
+ str += `${indent}${quotedKey}: (values: { ${params} }) => string,\n`
+ } else if ((entry as TextEntry).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\n`
+ output += `import { ICUUtil, Translation } from '@helpwave/internationalization'\n\n`
+
+ output += `export const supportedLocales = [${[
+ ...locales.values()
+ ]
+ .map(v => `'${v}'`)
+ .join(', ')}] as const\n\n`
+
+ output += `export type SupportedLocale = typeof supportedLocales[number]\n\n`
+
+ output += `export type GeneratedTranslationEntries = {\n${generateType(
+ translationData
+ )}}\n\n`
+
+ output += `export const generatedTranslations: Translation> = {\n${generateCode(
+ translationData
+ )}}\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/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..8529ffe
--- /dev/null
+++ b/tests/compiler.test.ts
@@ -0,0 +1,177 @@
+import {ICUASTNode, ICUCompilerValues, 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: 'Text', value: '# apple'},
+ 'other': {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: '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: 'Text', value: 'Admin, # messages'}
+ }
+ },
+ member: {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=1': {type: 'Text', value: 'Member, 1 message'},
+ 'other': {type: 'Text', value: 'Member, # 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: 'Text', value: '# cat'},
+ 'other': {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..0a5714a
--- /dev/null
+++ b/tests/interpreter.test.ts
@@ -0,0 +1,62 @@
+import {ICUCompilerValues, 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..763f54f
--- /dev/null
+++ b/tests/lexer.test.ts
@@ -0,0 +1,287 @@
+import {ICUToken, 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: 'TEXT', value: '#' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'apple' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: '#' },
+ { 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: 'TEXT', value: '#' },
+ { 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: 'TEXT', value: '#' },
+ { 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: 'TEXT', value: '#' },
+ { 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: 'TEXT', value: '{special}' },
+ { 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: 'TEXT', value: '#' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'cat' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: '#' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'cats' },
+ { type: 'RBRACE' },
+ { type: 'RBRACE' }
+ ],
+ },
+ {
+ name: 'Escape sequence',
+ message: "'''''' '{}'",
+ result: [
+ { type: 'TEXT', value: "'''" },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '{}' },
+ ],
+ }
+]
+
+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..17f759d
--- /dev/null
+++ b/tests/parser.test.ts
@@ -0,0 +1,400 @@
+import {ICUASTNode, ICUToken, 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: 'TEXT', value: '#' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'apple' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: '#' },
+ { 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: 'Text', value: '# apple' },
+ 'other': { 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: 'TEXT', value: '#' },
+ { 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: '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: 'TEXT', value: '#' },
+ { 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: 'TEXT', value: '#' },
+ { 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: 'Text', value: 'Admin, # messages' }
+ }
+ },
+ member: {
+ type: 'OptionReplace',
+ operatorName: 'plural',
+ variableName: 'count',
+ options: {
+ '=1': { type: 'Text', value: 'Member, 1 message' },
+ 'other': { type: 'Text', value: 'Member, # 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: 'TEXT', value: '{special}' },
+ { 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: 'TEXT', value: '#' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'cat' },
+ { type: 'RBRACE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: 'other' },
+ { type: 'LBRACE' },
+ { type: 'TEXT', value: '#' },
+ { 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: 'Text', value: '# cat' },
+ 'other': { type: 'Text', value: '# cats' }
+ }
+ }
+ ]
+ },
+ },
+ {
+ name: 'Escape sequence',
+ input: [
+ { type: 'TEXT', value: "'''" },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'TEXT', value: '{}' },
+ ],
+ result: {
+ type: 'Text',
+ value: "''' {}",
+ },
+ }
+]
+
+describe('ICU Parser', () => {
+ for (const example of examples) {
+ test(`${example.name}:`, () => {
+ const result = ICUUtil.parse(example.input)
+ 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',
+})
From 822e0c0b7f21b182332304f01f001dcf1aab13ed Mon Sep 17 00:00:00 2001
From: DasProffi <67233923+DasProffi@users.noreply.github.com>
Date: Thu, 20 Nov 2025 23:57:02 +0100
Subject: [PATCH 2/4] feat: simplify lexer, parser, compiler and add test
github action
---
.github/workflows/test.yaml | 20 ++
src/icu.ts | 481 +++++++++++++++++++-----------------
tests/compiler.test.ts | 113 ++++++---
tests/lexer.test.ts | 39 ++-
tests/parser.test.ts | 97 ++++++--
5 files changed, 463 insertions(+), 287 deletions(-)
create mode 100644 .github/workflows/test.yaml
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 0000000..118f405
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,20 @@
+name: Linting
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - '**'
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v3
+ with:
+ node-version: "20"
+ - run: npm ci
+ - run: npm run test
\ No newline at end of file
diff --git a/src/icu.ts b/src/icu.ts
index 2486c51..feb62e3 100644
--- a/src/icu.ts
+++ b/src/icu.ts
@@ -4,21 +4,15 @@ const escapeCharacter = "'"
// Lexer
/////////////
-// TODO treat # as a special case
-// TODO consider ' the escape character as a special case
export type ICUToken =
| { type: 'LBRACE' }
| { type: 'RBRACE' }
| { type: 'COMMA' }
+ | { type: 'HASHTAG' }
+ | { type: 'ESCAPE' }
| { type: 'WHITESPACE', value: string }
| { type: 'TEXT', value: string }
-type LexerContext = {
- buffer: string,
- state: 'whitespace' | 'escape' | 'normal',
- lastWasEscape: boolean,
-}
-
/**
* ICU uses single quotes to quote literal text. This means:
* '' -> '
@@ -27,32 +21,19 @@ type LexerContext = {
function lex(input: string): ICUToken[] {
const tokens: ICUToken[] = []
- const context: LexerContext = {
- buffer: '',
- state: 'normal',
- lastWasEscape: false,
- }
-
- function resetContext() {
- context.buffer = ''
- context.state = 'normal'
- context.lastWasEscape = false
- }
-
- function pushText(text: string) {
+ function pushAppend(text: string, type: 'TEXT' | 'WHITESPACE') {
if (tokens.length > 0) {
const last = tokens[tokens.length - 1]
- if (last.type === 'TEXT') {
+ if (last.type === type) {
last.value += text
- } else {
- tokens.push({ type: 'TEXT', value: text })
+ return
}
- } else {
- tokens.push({ type: 'TEXT', value: text })
}
+ tokens.push({ type, value: text })
}
- function inNormal(character: string) {
+ for (let index = 0; index < input.length; index++) {
+ const character = input[index]
switch (character) {
case '{':
tokens.push({ type: 'LBRACE' })
@@ -60,70 +41,24 @@ function lex(input: string): ICUToken[] {
case '}':
tokens.push({ type: 'RBRACE' })
break
+ case '#':
+ tokens.push({ type: 'HASHTAG' })
+ break
case ',':
tokens.push({ type: 'COMMA' })
break
- case ' ':
- context.state = 'whitespace'
- context.buffer += character
- break
case escapeCharacter:
- context.lastWasEscape = true
- context.state = 'escape'
+ tokens.push({ type: 'ESCAPE' })
break
- default:
- pushText(character)
- break
- }
- }
-
- function inEscape(character: string) {
- switch (character) {
- case escapeCharacter: {
- const text = context.lastWasEscape ? escapeCharacter : context.buffer
- pushText(text)
- resetContext()
- break
- }
- default:
- context.buffer += character
- context.lastWasEscape = false
- break
- }
- }
-
- function inWhitespace(character: string) {
- switch (character) {
case ' ':
- context.buffer += character
+ pushAppend(character, 'WHITESPACE')
break
default:
- tokens.push({ type: 'WHITESPACE', value: context.buffer })
- resetContext()
- inNormal(character)
+ pushAppend(character, 'TEXT')
break
}
}
- for (let index = 0; index < input.length; index++) {
- const character = input[index]
- if (context.state === 'escape') {
- inEscape(character)
- } else if (context.state === 'whitespace') {
- inWhitespace(character)
- } else {
- inNormal(character)
- }
- }
-
- // Handle final states
- if (context.state === 'whitespace') {
- tokens.push({ type: 'WHITESPACE', value: context.buffer })
- } else if (context.state === 'escape') {
- // The escape might not be closed here (should be solved in parser)
- pushText(context.buffer)
- }
-
return tokens
}
@@ -131,173 +66,272 @@ function lex(input: string): ICUToken[] {
// 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', operatorName: string, variableName: string, options: Record } // {var, select, key{msg} ...}
+ | { type: 'OptionReplace', variableName: string, operatorName: ReplaceOperation, options: Record } // {var, select, key{msg} ...}
-type ParserContext = {
- subTree: ICUToken[],
- openBrackets: number,
-}
+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 ParserReplaceContext = {
- subTree: ICUToken[],
- openBrackets: number,
- variableName?: string,
- variableComma: boolean,
- operatorName?: string,
- operatorComma: boolean,
- optionName?: string,
- options: Record,
+type ParserContext = {
+ state: ParserState[],
+ last?: ICUToken,
}
function parse(tokens: ICUToken[]): ICUASTNode {
const result: ICUASTNode[] = []
- // Closing and opening brackets are already removed
- function parseReplace(tokens: ICUToken[]): ICUASTNode {
- if (tokens.length === 0) {
- return { type: 'Text', value: '' }
- } else if (
- tokens.every(value => value.type === 'TEXT' || value.type === 'WHITESPACE') &&
- tokens.filter(value => value.type === 'TEXT').length === 1
- ) {
- return {
- type: 'SimpleReplace',
- variableName: tokens.filter(value => value.type === 'TEXT')[0].value
+ 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 })
+ }
- const context: ParserReplaceContext = {
- subTree: [],
- openBrackets: 0,
- options: {},
- variableComma: false,
- operatorComma: false,
+ 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
}
- for (let index = 0; index < tokens.length; index++) {
- const token = tokens[index]
- switch (token.type) {
- case 'TEXT': {
- if (context.openBrackets > 0) {
- context.subTree.push(token)
- } else if (!context.variableName) {
- context.variableName = token.value
- } else if (!context.operatorName && context.variableComma) {
- context.operatorName = token.value
- } else if (!context.optionName && context.operatorComma) {
- context.optionName = token.value
- } else {
- throw Error(`ICU Parse: Encountered unexpected ${token.type} token`)
- }
- 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)
}
- case 'COMMA': {
- if (context.openBrackets > 0) {
- context.subTree.push(token)
- } else if (context.operatorName && !context.operatorComma) {
- context.operatorComma = true
- } else if (context.variableName && !context.variableComma) {
- context.variableComma = true
+ 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 {
- throw Error(`ICU Parse: Encountered unexpected ${token.type} token`)
+ result.push(node)
}
- break
- }
- case 'WHITESPACE': {
- if (context.openBrackets > 0) {
- context.subTree.push(token)
+ } 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,
}
- break
- }
- case 'LBRACE': {
- if (context.optionName) {
- if (context.openBrackets > 0) {
- context.subTree.push(token)
- }
- context.openBrackets++
+ if (prevState.name === 'replaceFunction') {
+ prevState.subtree.push(node)
} else {
- throw Error(`ICU Parse: Encountered unexpected ${token.type} token`)
+ result.push(node)
}
- break
+ } else {
+ throw Error(`ICU Parser: Invalid placement of "}" in replacement function.`)
}
- case 'RBRACE': {
- if (context.optionName) {
- context.openBrackets--
- if (context.openBrackets < 0) {
- throw new Error('ICU Parse: Mismatching amount of closing and opening brackets')
- }
- if (context.openBrackets === 0) {
- context.options[context.optionName] = parse(context.subTree)
- context.subTree = []
- context.optionName = undefined
- } else {
- context.subTree.push(token)
- }
+ break
+ case 'HASHTAG': {
+ if (state.expect === 'optionContentOrClose') {
+ if (state.operatorName === 'plural') {
+ state.subtree.push({ type: 'NumberField' })
} else {
- throw Error(`ICU Parse: Encountered unexpected ${token.type} token`)
+ pushText('#', state.subtree)
}
- break
+ } else {
+ throw Error(`ICU Parser: Invalid placement of "#". "#" are only valid outside of replacement functions or in the option content.`)
}
+ break
}
- }
- if (context.openBrackets > 0) {
- throw new Error('ICU Parse: Mismatching amount of closing and opening brackets')
- }
- if (!context.operatorName && !context.variableName && Object.keys(context.options).length === 0) {
- throw new Error('ICU Parse: Not a valid OptionReplace')
- }
- return {
- type: 'OptionReplace',
- operatorName: context.operatorName,
- variableName: context.variableName,
- options: context.options,
+ 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
}
}
- const context: ParserContext = {
- openBrackets: 0,
- subTree: []
- }
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index]
- if (token.type === 'TEXT' || token.type === 'WHITESPACE' || token.type === 'COMMA') {
- if (context.openBrackets > 0) {
- context.subTree.push(token)
- } else {
- let text = ''
- if (token.type === 'TEXT' || token.type === 'WHITESPACE') {
- text += token.value
- } else if (token.type === 'COMMA') {
- text += ','
- }
- if (result.length > 0 && result[result.length - 1].type === 'Text') {
- (result[result.length - 1] as { type: 'Text', value: string }).value += text
- } else {
- result.push({ type: 'Text', value: text })
- }
- }
- } else if (token.type === 'RBRACE') {
- context.openBrackets--
- if (context.openBrackets < 0) {
- throw Error(`ICU Parse: Encountered "}" without a prior "{"`)
- } else if (context.openBrackets === 0) {
- result.push(parseReplace(context.subTree))
- context.subTree = []
- } else if (context.openBrackets > 0) {
- context.subTree.push(token)
- }
- } else if (token.type === 'LBRACE') {
- if (context.openBrackets > 0) {
- context.subTree.push(token)
- }
- context.openBrackets++
+ const state = getStateName()
+
+ if (state === 'normal') {
+ inNormal(token)
+ } else if (state === 'replaceFunction') {
+ inReplaceFunction(token)
+ } else if (state === 'escape') {
+ inEscape(token)
}
+ context.last = token
}
- if (context.openBrackets > 0) {
+
+ 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]
}
@@ -306,13 +340,17 @@ function parse(tokens: ICUToken[]): ICUASTNode {
// Compiler
/////////////
+type CompilerContext = {
+ hashtagReplacer?: number,
+}
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ICUCompilerValues = Record
-function compile(node: ICUASTNode, values: ICUCompilerValues): string {
+function compile(node: ICUASTNode, values: ICUCompilerValues, context: CompilerContext = {}): string {
switch (node.type) {
case 'Node': {
- return node.parts.map(p => compile(p, values)).join('')
+ return node.parts.map(p => compile(p, values, context)).join('')
}
case 'Text':
return node.value
@@ -345,29 +383,32 @@ function compile(node: ICUASTNode, values: ICUCompilerValues): string {
console.warn(`ICU Compile: plural for ${name} could not find key ${pluralKey} and no other`)
return `{${name}}`
}
- const result = compile(chosen, values)
- return result.replace(/#/g, String(num))
+ 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) : `{${name}}`
+ 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)
+ return compile(chosen, values, context)
}
default: {
return `{${name}, ${operation}}`
}
}
}
- default: {
- return ''
+ case 'NumberField': {
+ if (context.hashtagReplacer !== undefined) {
+ return `${context.hashtagReplacer}`
+ } else {
+ return '{#}'
+ }
}
}
}
diff --git a/tests/compiler.test.ts b/tests/compiler.test.ts
index 8529ffe..8dd06c9 100644
--- a/tests/compiler.test.ts
+++ b/tests/compiler.test.ts
@@ -1,4 +1,5 @@
-import {ICUASTNode, ICUCompilerValues, ICUUtil} from "../src";
+import type { ICUASTNode, ICUCompilerValues } from '../src'
+import { ICUUtil } from '../src'
type ExampleValues = {
name: string,
@@ -10,30 +11,42 @@ type ExampleValues = {
const examples: ExampleValues[] = [
{
name: 'Simple Replace',
- values: {name: 'Alice'},
+ values: { name: 'Alice' },
input: {
type: 'Node',
parts: [
- {type: 'Text', value: 'Hello '},
- {type: 'SimpleReplace', variableName: 'name'}
+ { type: 'Text', value: 'Hello ' },
+ { type: 'SimpleReplace', variableName: 'name' }
]
},
result: 'Hello Alice',
},
{
name: 'Plural with number insertion',
- values: {count: 1},
+ values: { count: 1 },
input: {
type: 'Node',
parts: [
- {type: 'Text', value: 'You have '},
+ { type: 'Text', value: 'You have ' },
{
type: 'OptionReplace',
operatorName: 'plural',
variableName: 'count',
options: {
- '=1': {type: 'Text', value: '# apple'},
- 'other': {type: 'Text', value: '# apples'}
+ '=1': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' apple' }
+ ]
+ },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' apples' }
+ ]
+ }
}
}
]
@@ -42,7 +55,7 @@ const examples: ExampleValues[] = [
},
{
name: 'Select with nested replacement',
- values: {gender: 'female', name: 'Lee'},
+ values: { gender: 'female', name: 'Lee' },
input: {
type: 'OptionReplace',
operatorName: 'select',
@@ -51,22 +64,22 @@ const examples: ExampleValues[] = [
male: {
type: 'Node',
parts: [
- {type: 'Text', value: 'Hello Mr. '},
- {type: 'SimpleReplace', variableName: 'name'}
+ { type: 'Text', value: 'Hello Mr. ' },
+ { type: 'SimpleReplace', variableName: 'name' }
]
},
female: {
type: 'Node',
parts: [
- {type: 'Text', value: 'Hello Ms. '},
- {type: 'SimpleReplace', variableName: 'name'}
+ { type: 'Text', value: 'Hello Ms. ' },
+ { type: 'SimpleReplace', variableName: 'name' }
]
},
other: {
type: 'Node',
parts: [
- {type: 'Text', value: 'Hello '},
- {type: 'SimpleReplace', variableName: 'name'}
+ { type: 'Text', value: 'Hello ' },
+ { type: 'SimpleReplace', variableName: 'name' }
]
}
}
@@ -75,7 +88,7 @@ const examples: ExampleValues[] = [
},
{
name: 'Plural and Select in succession',
- values: {count: 0, gender: 'male'},
+ values: { count: 0, gender: 'male' },
input: {
type: 'Node',
parts: [
@@ -84,19 +97,24 @@ const examples: ExampleValues[] = [
operatorName: 'plural',
variableName: 'count',
options: {
- '=0': {type: 'Text', value: 'no items'},
- '=1': {type: 'Text', value: 'one item'},
- 'other': {type: 'Text', value: '# items'}
+ '=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: 'Text', value: ' and ' },
{
type: 'OptionReplace',
operatorName: 'select',
variableName: 'gender',
options: {
- male: {type: 'Text', value: 'sir'},
- other: {type: 'Text', value: 'friend'}
+ male: { type: 'Text', value: 'sir' },
+ other: { type: 'Text', value: 'friend' }
}
}
]
@@ -105,7 +123,7 @@ const examples: ExampleValues[] = [
},
{
name: 'Plural nested in Select',
- values: {userType: 'member', count: 3},
+ values: { userType: 'member', count: 3 },
input: {
type: 'OptionReplace',
operatorName: 'select',
@@ -116,8 +134,15 @@ const examples: ExampleValues[] = [
operatorName: 'plural',
variableName: 'count',
options: {
- '=1': {type: 'Text', value: 'Admin, 1 message'},
- 'other': {type: 'Text', value: 'Admin, # messages'}
+ '=1': { type: 'Text', value: 'Admin, 1 message' },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Admin, ' },
+ { type: 'NumberField' },
+ { type: 'Text', value: ' messages' }
+ ]
+ }
}
},
member: {
@@ -125,11 +150,18 @@ const examples: ExampleValues[] = [
operatorName: 'plural',
variableName: 'count',
options: {
- '=1': {type: 'Text', value: 'Member, 1 message'},
- 'other': {type: 'Text', value: 'Member, # messages'}
+ '=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'}
+ other: { type: 'Text', value: 'Guest' }
}
},
result:
@@ -137,18 +169,30 @@ const examples: ExampleValues[] = [
},
{
name: 'Replace, Escape and Plural',
- values: {count: 2},
+ values: { count: 2 },
input: {
type: 'Node',
parts: [
- {type: 'Text', value: 'Today is {special} and you have '},
+ { type: 'Text', value: 'Today is {special} and you have ' },
{
type: 'OptionReplace',
operatorName: 'plural',
variableName: 'count',
options: {
- '=1': {type: 'Text', value: '# cat'},
- 'other': {type: 'Text', value: '# cats'}
+ '=1': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' cat' }
+ ]
+ },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' cats' }
+ ]
+ }
}
}
]
@@ -158,10 +202,7 @@ const examples: ExampleValues[] = [
{
name: 'Escape sequence',
values: {},
- input: {
- type: 'Text',
- value: "''' {}",
- },
+ input: { type: 'Text', value: "''' {}" },
result: "''' {}",
}
]
diff --git a/tests/lexer.test.ts b/tests/lexer.test.ts
index 763f54f..d7ab3a0 100644
--- a/tests/lexer.test.ts
+++ b/tests/lexer.test.ts
@@ -1,4 +1,5 @@
-import {ICUToken, ICUUtil} from "../src";
+import type { ICUToken } from '../src'
+import { ICUUtil } from '../src'
type ExampleValues = {
name: string,
@@ -35,14 +36,14 @@ const examples: ExampleValues[] = [
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: '=1' },
{ type: 'LBRACE' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'apple' },
{ type: 'RBRACE' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'other' },
{ type: 'LBRACE' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'apples' },
{ type: 'RBRACE' },
@@ -120,7 +121,7 @@ const examples: ExampleValues[] = [
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'other' },
{ type: 'LBRACE' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'items' },
{ type: 'RBRACE' },
@@ -182,7 +183,7 @@ const examples: ExampleValues[] = [
{ type: 'TEXT', value: 'Admin' },
{ type: 'COMMA' },
{ type: 'WHITESPACE', value: ' ' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'messages' },
{ type: 'RBRACE' },
@@ -213,7 +214,7 @@ const examples: ExampleValues[] = [
{ type: 'TEXT', value: 'Member' },
{ type: 'COMMA' },
{ type: 'WHITESPACE', value: ' ' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'messages' },
{ type: 'RBRACE' },
@@ -235,7 +236,13 @@ const examples: ExampleValues[] = [
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'is' },
{ type: 'WHITESPACE', value: ' ' },
- { type: 'TEXT', value: '{special}' },
+ { 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: ' ' },
@@ -252,14 +259,14 @@ const examples: ExampleValues[] = [
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: '=1' },
{ type: 'LBRACE' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'cat' },
{ type: 'RBRACE' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'other' },
{ type: 'LBRACE' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'cats' },
{ type: 'RBRACE' },
@@ -270,9 +277,17 @@ const examples: ExampleValues[] = [
name: 'Escape sequence',
message: "'''''' '{}'",
result: [
- { type: 'TEXT', value: "'''" },
- { type: 'WHITESPACE', value: ' ' },
- { type: 'TEXT', value: '{}' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'ESCAPE' },
+ { type: 'WHITESPACE', value: ' ' },
+ { type: 'ESCAPE' },
+ { type: 'LBRACE' },
+ { type: 'RBRACE' },
+ { type: 'ESCAPE' },
],
}
]
diff --git a/tests/parser.test.ts b/tests/parser.test.ts
index 17f759d..83f7c5c 100644
--- a/tests/parser.test.ts
+++ b/tests/parser.test.ts
@@ -1,4 +1,5 @@
-import {ICUASTNode, ICUToken, ICUUtil} from "../src";
+import type { ICUASTNode, ICUToken } from '../src'
+import { ICUUtil } from '../src'
type ExampleValues = {
name: string,
@@ -40,14 +41,14 @@ const examples: ExampleValues[] = [
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: '=1' },
{ type: 'LBRACE' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'apple' },
{ type: 'RBRACE' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'other' },
{ type: 'LBRACE' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'apples' },
{ type: 'RBRACE' },
@@ -62,8 +63,20 @@ const examples: ExampleValues[] = [
operatorName: 'plural',
variableName: 'count',
options: {
- '=1': { type: 'Text', value: '# apple' },
- 'other': { type: 'Text', value: '# apples' }
+ '=1': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' apple' }
+ ]
+ },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' apples' }
+ ]
+ }
}
}
]
@@ -166,7 +179,7 @@ const examples: ExampleValues[] = [
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'other' },
{ type: 'LBRACE' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'items' },
{ type: 'RBRACE' },
@@ -202,7 +215,12 @@ const examples: ExampleValues[] = [
options: {
'=0': { type: 'Text', value: 'no items' },
'=1': { type: 'Text', value: 'one item' },
- 'other': { type: 'Text', value: '# items' }
+ 'other': {
+ type: 'Node', parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' items' }
+ ]
+ }
}
},
{ type: 'Text', value: ' and ' },
@@ -252,7 +270,7 @@ const examples: ExampleValues[] = [
{ type: 'TEXT', value: 'Admin' },
{ type: 'COMMA' },
{ type: 'WHITESPACE', value: ' ' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'messages' },
{ type: 'RBRACE' },
@@ -283,7 +301,7 @@ const examples: ExampleValues[] = [
{ type: 'TEXT', value: 'Member' },
{ type: 'COMMA' },
{ type: 'WHITESPACE', value: ' ' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'messages' },
{ type: 'RBRACE' },
@@ -307,7 +325,14 @@ const examples: ExampleValues[] = [
variableName: 'count',
options: {
'=1': { type: 'Text', value: 'Admin, 1 message' },
- 'other': { type: 'Text', value: 'Admin, # messages' }
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Admin, ' },
+ { type: 'NumberField' },
+ { type: 'Text', value: ' messages' }
+ ]
+ }
}
},
member: {
@@ -316,7 +341,14 @@ const examples: ExampleValues[] = [
variableName: 'count',
options: {
'=1': { type: 'Text', value: 'Member, 1 message' },
- 'other': { type: 'Text', value: 'Member, # messages' }
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'Text', value: 'Member, ' },
+ { type: 'NumberField' },
+ { type: 'Text', value: ' messages' }
+ ]
+ }
}
},
other: { type: 'Text', value: 'Guest' }
@@ -330,7 +362,13 @@ const examples: ExampleValues[] = [
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'is' },
{ type: 'WHITESPACE', value: ' ' },
- { type: 'TEXT', value: '{special}' },
+ { 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: ' ' },
@@ -347,14 +385,14 @@ const examples: ExampleValues[] = [
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: '=1' },
{ type: 'LBRACE' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'cat' },
{ type: 'RBRACE' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'other' },
{ type: 'LBRACE' },
- { type: 'TEXT', value: '#' },
+ { type: 'HASHTAG' },
{ type: 'WHITESPACE', value: ' ' },
{ type: 'TEXT', value: 'cats' },
{ type: 'RBRACE' },
@@ -369,8 +407,20 @@ const examples: ExampleValues[] = [
operatorName: 'plural',
variableName: 'count',
options: {
- '=1': { type: 'Text', value: '# cat' },
- 'other': { type: 'Text', value: '# cats' }
+ '=1': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' cat' }
+ ]
+ },
+ 'other': {
+ type: 'Node',
+ parts: [
+ { type: 'NumberField' },
+ { type: 'Text', value: ' cats' }
+ ]
+ }
}
}
]
@@ -379,9 +429,17 @@ const examples: ExampleValues[] = [
{
name: 'Escape sequence',
input: [
- { type: 'TEXT', value: "'''" },
- { type: 'WHITESPACE', value: ' ' },
- { type: 'TEXT', value: '{}' },
+ { 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',
@@ -394,6 +452,7 @@ 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)
})
}
From f4ccac17580b57e0197e08086b54814f63130985 Mon Sep 17 00:00:00 2001
From: DasProffi <67233923+DasProffi@users.noreply.github.com>
Date: Fri, 21 Nov 2025 00:07:10 +0100
Subject: [PATCH 3/4] chore: fix lint
---
.github/workflows/test.yaml | 2 +-
src/combineTranslation.ts | 2 +-
src/index.ts | 6 ++---
src/scripts/compile-arb.ts | 44 ++++++++++++++++++-------------------
tests/interpreter.test.ts | 3 ++-
5 files changed, 29 insertions(+), 28 deletions(-)
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 118f405..b6e9d1b 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -1,4 +1,4 @@
-name: Linting
+name: Test
on:
push:
diff --git a/src/combineTranslation.ts b/src/combineTranslation.ts
index 57df329..72f270f 100644
--- a/src/combineTranslation.ts
+++ b/src/combineTranslation.ts
@@ -1,4 +1,4 @@
-import {PartialTranslation, TranslationEntries} from "@/src/types";
+import type { PartialTranslation, TranslationEntries } from '@/src/types'
type Exact = U;
diff --git a/src/index.ts b/src/index.ts
index 1fdc6bd..7b41095 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,3 @@
-export * from "./icu"
-export * from "./combineTranslation"
-export * from "./types"
\ No newline at end of file
+export * from './icu'
+export * from './combineTranslation'
+export * from './types'
\ No newline at end of file
diff --git a/src/scripts/compile-arb.ts b/src/scripts/compile-arb.ts
index 29d1978..a8e6fa4 100644
--- a/src/scripts/compile-arb.ts
+++ b/src/scripts/compile-arb.ts
@@ -6,47 +6,48 @@ import readline from 'readline'
/* ------------------ types ------------------ */
interface PlaceholderMeta {
- type?: string
+ type?: string,
}
interface ARBPlaceholders {
- [key: string]: PlaceholderMeta
+ [key: string]: PlaceholderMeta,
}
interface ARBMeta {
- placeholders?: ARBPlaceholders
+ placeholders?: ARBPlaceholders,
}
interface ARBFile {
- [key: string]: string | ARBMeta
+ [key: string]: string | ARBMeta,
}
interface FuncParam {
- name: string
- typing: string
+ name: string,
+ typing: string,
}
interface TextEntry {
- type: 'text'
- value: string
+ type: 'text',
+ value: string,
}
interface FuncEntry {
- type: 'func'
- params: FuncParam[]
- value: string
+ type: 'func',
+ params: FuncParam[],
+ value: string,
}
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TranslationEntry = TextEntry | FuncEntry | Record
/* ------------------ CLI args ------------------ */
function parseArgs() {
const args = process.argv.slice(2)
const result: {
- input?: string
- outputFile?: string
- force: boolean
- help: boolean
+ input?: string,
+ outputFile?: string,
+ force: boolean,
+ help: boolean,
} = {
force: false,
help: false
@@ -124,8 +125,7 @@ function askQuestion(query: string): Promise {
rl.question(query, ans => {
rl.close()
resolve(ans)
- })
- )
+ }))
}
/* ------------------ I/O helpers ------------------ */
@@ -134,8 +134,7 @@ if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
-function toSingleQuote(str: any): string {
- if (typeof str !== 'string') return str
+function toSingleQuote(str: string): string {
return `'${str.replace(/'/g, "\\'")}'`
}
@@ -178,6 +177,7 @@ function readARBDir(
const meta = content[`@${key}`] as ARBMeta | undefined
const flatKey = prefix ? `${prefix}.${key}` : key
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
const entryObj: any = {}
if (meta?.placeholders) {
@@ -201,7 +201,7 @@ function readARBDir(
entryObj.type = 'func'
entryObj.params = params
entryObj.value = `(values): string => ICUUtil.interpret(${toSingleQuote(
- value
+ String(value)
)}, values)`
} else {
// plain text
@@ -224,8 +224,7 @@ function generateCode(
): string {
const indent = ' '.repeat(indentLevel)
const entries = Object.entries(obj).sort((a, b) =>
- a[0].localeCompare(b[0])
- )
+ a[0].localeCompare(b[0]))
let str = ''
@@ -243,6 +242,7 @@ function generateCode(
} else {
// nested object
str += `${indent}${quotedKey}: {\n`
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
str += generateCode(entry as any, indentLevel + 1)
str += `${indent}}${comma}\n`
}
diff --git a/tests/interpreter.test.ts b/tests/interpreter.test.ts
index 0a5714a..6372ec8 100644
--- a/tests/interpreter.test.ts
+++ b/tests/interpreter.test.ts
@@ -1,4 +1,5 @@
-import {ICUCompilerValues, ICUUtil} from "../src";
+import type { ICUCompilerValues } from '../src'
+import { ICUUtil } from '../src'
type TestValues = {
name: string,
From a49db36545aa0c17b807986ff9ab8b6f1ad91e9e Mon Sep 17 00:00:00 2001
From: DasProffi <67233923+DasProffi@users.noreply.github.com>
Date: Fri, 21 Nov 2025 04:17:31 +0100
Subject: [PATCH 4/4] chore: optimize the translation file and add examples
---
README.md | 30 +++-
examples/locales/de-DE.arb | 76 ++++++++++
examples/locales/en-US.arb | 76 ++++++++++
examples/locales/fr-FR.arb | 7 +
examples/translations/translation.ts | 213 +++++++++++++++++++++++++++
src/index.ts | 3 +-
src/scripts/compile-arb.ts | 186 +++++++++++++++++------
src/translationGeneration.ts | 25 ++++
8 files changed, 568 insertions(+), 48 deletions(-)
create mode 100644 examples/locales/de-DE.arb
create mode 100644 examples/locales/en-US.arb
create mode 100644 examples/locales/fr-FR.arb
create mode 100644 examples/translations/translation.ts
create mode 100644 src/translationGeneration.ts
diff --git a/README.md b/README.md
index b17e474..8a9b1c6 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,31 @@
helpwaves package for internationalization that creates localized and typesafe translation based on ARB files.
## Usage
+Create a `.arb` file with your translations:
+```json
+"priceInfo": "The price is {price} €{currency, select, usd{USD} eur{EUR} other{}}.",
+"@priceInfo": {
+ "placeholders": {
+ "price": {
+ "type": "number"
+ },
+ "currency": {}
+ }
+}
+```
+And get a translation:
+
+```typescript
+import {combineTranslation} from "./combineTranslation";
+
+translations["en-US"].priceInfo(price, currency)
+
+const t = combineTranslation([translation1, translation2], "en-US")
+// v still typesafe on both function parameters
+t("priceInfo", { price, currency })
+```
+
+## Getting Started
#### Install the package
```
npm install -D @helpwave/internationalization
@@ -36,4 +61,7 @@ Options:
## Tests
-The lexer, parser and compiler are all tested.
\ No newline at end of file
+The lexer, parser and compiler are all tested with jest, see [our tests](/tests)
+
+## Examples
+Example translation files and the resulting translation can be found in the [examples folder](/examples).
\ No newline at end of file
diff --git a/examples/locales/de-DE.arb b/examples/locales/de-DE.arb
new file mode 100644
index 0000000..2a73a5a
--- /dev/null
+++ b/examples/locales/de-DE.arb
@@ -0,0 +1,76 @@
+{
+ "userGreeting": "{gender, select, male{Hallo, {name}!} female{Hallo, {name}!} other{Hallo, Person!}}",
+ "@userGreeting": {
+ "placeholders": {
+ "gender": {},
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "itemCount": "{count, plural, =0{Keine Elemente} =1{Ein Element} other{{count} Elemente}}",
+ "@itemCount": {
+ "placeholders": {
+ "count": {
+ "type": "number"
+ }
+ }
+ },
+ "accountStatus": "{status, select, active{Aktiv} inactive{Inaktiv} other{Unbekannt}}",
+ "@accountStatus": {
+ "placeholders": {
+ "status": {}
+ }
+ },
+ "ageCategory": "{ageGroup, select, child{Kind} adult{Erwachsener} senior{Senior} other{Person}}",
+ "@ageCategory": {
+ "placeholders": {
+ "ageGroup": {}
+ }
+ },
+ "passwordStrength": "{strength, select, weak{Schwach} medium{Mittel} strong{Stark} other{Unbekannt}}",
+ "@passwordStrength": {
+ "placeholders": {
+ "strength": {}
+ }
+ },
+ "welcomeMessage": "{gender, select, male{Willkommen, {name}!} female{Willkommen, {name}!} other{Willkommen, Person!}} Du hast {count, plural, =0{keine neuen Nachrichten} =1{eine neue Nachricht} other{{count} neue Nachrichten}}.",
+ "@welcomeMessage": {
+ "placeholders": {
+ "gender": {},
+ "name": {
+ "type": "string"
+ },
+ "count": {
+ "type": "number"
+ }
+ }
+ },
+ "priceInfo": "Der Preis beträgt {price} €{currency, select, usd{USD} eur{EUR} other{}}.",
+ "@priceInfo": {
+ "placeholders": {
+ "price": {
+ "type": "number"
+ },
+ "currency": {}
+ }
+ },
+ "taskDeadline": "Die Aufgabe muss bis {deadline} erledigt sein.",
+ "@taskDeadline": {
+ "placeholders": {
+ "deadline": {
+ "type": "string"
+ }
+ }
+ },
+ "escapedExample": "Folgende Zeichen müssen escaped werden: '{' '}' ''",
+ "nestedSelectPlural": "{gender, select, male{{count, plural, =0{Keine Nachrichten} =1{Eine Nachricht} other{{count} Nachrichten}}} female{{count, plural, =0{Keine Nachrichten} =1{Eine Nachricht} other{{count} Nachrichten}}} other{{count, plural, =0{Keine Nachrichten} =1{Eine Nachricht} other{{count} Nachrichten}}}}",
+ "@nestedSelectPlural": {
+ "placeholders": {
+ "gender": {},
+ "count": {
+ "type": "number"
+ }
+ }
+ }
+}
diff --git a/examples/locales/en-US.arb b/examples/locales/en-US.arb
new file mode 100644
index 0000000..73f1b72
--- /dev/null
+++ b/examples/locales/en-US.arb
@@ -0,0 +1,76 @@
+{
+ "userGreeting": "{gender, select, male{Hello, {name}!} female{Hello, {name}!} other{Hello, person!}}",
+ "@userGreeting": {
+ "placeholders": {
+ "gender": {},
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "itemCount": "{count, plural, =0{No items} =1{One item} other{{count} items}}",
+ "@itemCount": {
+ "placeholders": {
+ "count": {
+ "type": "number"
+ }
+ }
+ },
+ "accountStatus": "{status, select, active{Active} inactive{Inactive} other{Unknown}}",
+ "@accountStatus": {
+ "placeholders": {
+ "status": {}
+ }
+ },
+ "ageCategory": "{ageGroup, select, child{Child} adult{Adult} senior{Senior} other{Person}}",
+ "@ageCategory": {
+ "placeholders": {
+ "ageGroup": {}
+ }
+ },
+ "passwordStrength": "{strength, select, weak{Weak} medium{Medium} strong{Strong} other{Unknown}}",
+ "@passwordStrength": {
+ "placeholders": {
+ "strength": {}
+ }
+ },
+ "welcomeMessage": "{gender, select, male{Welcome, {name}!} female{Welcome, {name}!} other{Welcome, person!}} You have {count, plural, =0{no new messages} =1{one new message} other{{count} new messages}}.",
+ "@welcomeMessage": {
+ "placeholders": {
+ "gender": {},
+ "name": {
+ "type": "string"
+ },
+ "count": {
+ "type": "number"
+ }
+ }
+ },
+ "priceInfo": "The price is {price} €{currency, select, usd{USD} eur{EUR} other{}}.",
+ "@priceInfo": {
+ "placeholders": {
+ "price": {
+ "type": "number"
+ },
+ "currency": {}
+ }
+ },
+ "taskDeadline": "The task must be completed by {deadline}.",
+ "@taskDeadline": {
+ "placeholders": {
+ "deadline": {
+ "type": "string"
+ }
+ }
+ },
+ "escapedExample": "The following characters must be escaped: '{' '}' ''",
+ "nestedSelectPlural": "{gender, select, male{{count, plural, =0{No messages} =1{One message} other{{count} messages}}} female{{count, plural, =0{No messages} =1{One message} other{{count} messages}}} other{{count, plural, =0{No messages} =1{One message} other{{count} messages}}}}",
+ "@nestedSelectPlural": {
+ "placeholders": {
+ "gender": {},
+ "count": {
+ "type": "number"
+ }
+ }
+ }
+}
diff --git a/examples/locales/fr-FR.arb b/examples/locales/fr-FR.arb
new file mode 100644
index 0000000..d5aafb1
--- /dev/null
+++ b/examples/locales/fr-FR.arb
@@ -0,0 +1,7 @@
+{
+ "hello": "Bonjour",
+ "goodbye": "Au revoir",
+ "thankYou": "Merci",
+ "welcome": "Bienvenue",
+ "yes": "Oui"
+}
diff --git a/examples/translations/translation.ts b/examples/translations/translation.ts
new file mode 100644
index 0000000..5194406
--- /dev/null
+++ b/examples/translations/translation.ts
@@ -0,0 +1,213 @@
+// AUTO-GENERATED. DO NOT EDIT.
+import type { Translation } from '@helpwave/internationalization'
+import { TranslationGen } from '@helpwave/internationalization'
+
+/* eslint-disable @stylistic/quote-props */
+export const supportedLocales = ['de-DE', 'en-US', 'fr-FR'] as const
+
+export type SupportedLocale = typeof supportedLocales[number]
+
+export type GeneratedTranslationEntries = {
+ 'accountStatus': (values: { status: string }) => string,
+ 'ageCategory': (values: { ageGroup: string }) => string,
+ 'escapedExample': string,
+ 'itemCount': (values: { count: number }) => string,
+ 'nestedSelectPlural': (values: { gender: string, count: number }) => string,
+ 'passwordStrength': (values: { strength: string }) => string,
+ 'priceInfo': (values: { price: number, currency: string }) => string,
+ 'taskDeadline': (values: { deadline: string }) => string,
+ 'userGreeting': (values: { gender: string, name: string }) => string,
+ 'welcomeMessage': (values: { gender: string, name: string, count: number }) => string,
+ 'goodbye': string,
+ 'hello': string,
+ 'thankYou': string,
+ 'welcome': string,
+ 'yes': string,
+}
+
+export const generatedTranslations: Translation> = {
+ 'de-DE': {
+ 'accountStatus': ({ status }): string => {
+ return TranslationGen.resolveSelect(status, {
+ 'active': `Aktiv`,
+ 'inactive': `Inaktiv`,
+ 'other': `Unbekannt`,
+ })
+ },
+ 'ageCategory': ({ ageGroup }): string => {
+ return TranslationGen.resolveSelect(ageGroup, {
+ 'child': `Kind`,
+ 'adult': `Erwachsener`,
+ 'senior': `Senior`,
+ 'other': `Person`,
+ })
+ },
+ 'escapedExample': `Folgende Zeichen müssen escaped werden: '{' '}' ''`,
+ 'itemCount': ({ count }): string => {
+ return TranslationGen.resolveSelect(count, {
+ '=0': `Keine Elemente`,
+ '=1': `Ein Element`,
+ 'other': `${count} Elemente`,
+ })
+ },
+ 'nestedSelectPlural': ({ gender, count }): string => {
+ return TranslationGen.resolveSelect(gender, {
+ 'male': TranslationGen.resolveSelect(count, {
+ '=0': `Keine Nachrichten`,
+ '=1': `Eine Nachricht`,
+ 'other': `${count} Nachrichten`,
+ }),
+ 'female': TranslationGen.resolveSelect(count, {
+ '=0': `Keine Nachrichten`,
+ '=1': `Eine Nachricht`,
+ 'other': `${count} Nachrichten`,
+ }),
+ 'other': TranslationGen.resolveSelect(count, {
+ '=0': `Keine Nachrichten`,
+ '=1': `Eine Nachricht`,
+ 'other': `${count} Nachrichten`,
+ }),
+ })
+ },
+ 'passwordStrength': ({ strength }): string => {
+ return TranslationGen.resolveSelect(strength, {
+ 'weak': `Schwach`,
+ 'medium': `Mittel`,
+ 'strong': `Stark`,
+ 'other': `Unbekannt`,
+ })
+ },
+ 'priceInfo': ({ price, currency }): string => {
+ let _out: string = ''
+ _out += `Der Preis beträgt ${price} €`
+ _out += TranslationGen.resolveSelect(currency, {
+ 'usd': `USD`,
+ 'eur': `EUR`,
+ })
+ _out += `.`
+ return _out
+ },
+ 'taskDeadline': ({ deadline }): string => {
+ return `Die Aufgabe muss bis ${deadline} erledigt sein.`
+ },
+ 'userGreeting': ({ gender, name }): string => {
+ return TranslationGen.resolveSelect(gender, {
+ 'male': `Hallo, ${name}!`,
+ 'female': `Hallo, ${name}!`,
+ 'other': `Hallo, Person!`,
+ })
+ },
+ 'welcomeMessage': ({ gender, name, count }): string => {
+ let _out: string = ''
+ _out += TranslationGen.resolveSelect(gender, {
+ 'male': `Willkommen, ${name}!`,
+ 'female': `Willkommen, ${name}!`,
+ 'other': `Willkommen, Person!`,
+ })
+ _out += ` Du hast `
+ _out += TranslationGen.resolveSelect(count, {
+ '=0': `keine neuen Nachrichten`,
+ '=1': `eine neue Nachricht`,
+ 'other': `${count} neue Nachrichten`,
+ })
+ _out += `.`
+ return _out
+ }
+ },
+ 'en-US': {
+ 'accountStatus': ({ status }): string => {
+ return TranslationGen.resolveSelect(status, {
+ 'active': `Active`,
+ 'inactive': `Inactive`,
+ 'other': `Unknown`,
+ })
+ },
+ 'ageCategory': ({ ageGroup }): string => {
+ return TranslationGen.resolveSelect(ageGroup, {
+ 'child': `Child`,
+ 'adult': `Adult`,
+ 'senior': `Senior`,
+ 'other': `Person`,
+ })
+ },
+ 'escapedExample': `The following characters must be escaped: '{' '}' ''`,
+ 'itemCount': ({ count }): string => {
+ return TranslationGen.resolveSelect(count, {
+ '=0': `No items`,
+ '=1': `One item`,
+ 'other': `${count} items`,
+ })
+ },
+ 'nestedSelectPlural': ({ gender, count }): string => {
+ return TranslationGen.resolveSelect(gender, {
+ 'male': TranslationGen.resolveSelect(count, {
+ '=0': `No messages`,
+ '=1': `One message`,
+ 'other': `${count} messages`,
+ }),
+ 'female': TranslationGen.resolveSelect(count, {
+ '=0': `No messages`,
+ '=1': `One message`,
+ 'other': `${count} messages`,
+ }),
+ 'other': TranslationGen.resolveSelect(count, {
+ '=0': `No messages`,
+ '=1': `One message`,
+ 'other': `${count} messages`,
+ }),
+ })
+ },
+ 'passwordStrength': ({ strength }): string => {
+ return TranslationGen.resolveSelect(strength, {
+ 'weak': `Weak`,
+ 'medium': `Medium`,
+ 'strong': `Strong`,
+ 'other': `Unknown`,
+ })
+ },
+ 'priceInfo': ({ price, currency }): string => {
+ let _out: string = ''
+ _out += `The price is ${price} €`
+ _out += TranslationGen.resolveSelect(currency, {
+ 'usd': `USD`,
+ 'eur': `EUR`,
+ })
+ _out += `.`
+ return _out
+ },
+ 'taskDeadline': ({ deadline }): string => {
+ return `The task must be completed by ${deadline}.`
+ },
+ 'userGreeting': ({ gender, name }): string => {
+ return TranslationGen.resolveSelect(gender, {
+ 'male': `Hello, ${name}!`,
+ 'female': `Hello, ${name}!`,
+ 'other': `Hello, person!`,
+ })
+ },
+ 'welcomeMessage': ({ gender, name, count }): string => {
+ let _out: string = ''
+ _out += TranslationGen.resolveSelect(gender, {
+ 'male': `Welcome, ${name}!`,
+ 'female': `Welcome, ${name}!`,
+ 'other': `Welcome, person!`,
+ })
+ _out += ` You have `
+ _out += TranslationGen.resolveSelect(count, {
+ '=0': `no new messages`,
+ '=1': `one new message`,
+ 'other': `${count} new messages`,
+ })
+ _out += `.`
+ return _out
+ }
+ },
+ 'fr-FR': {
+ 'goodbye': `Au revoir`,
+ 'hello': `Bonjour`,
+ 'thankYou': `Merci`,
+ 'welcome': `Bienvenue`,
+ 'yes': `Oui`
+ }
+}
+
diff --git a/src/index.ts b/src/index.ts
index 7b41095..8f66b30 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,4 @@
export * from './icu'
export * from './combineTranslation'
-export * from './types'
\ No newline at end of file
+export * from './types'
+export * from './translationGeneration'
diff --git a/src/scripts/compile-arb.ts b/src/scripts/compile-arb.ts
index a8e6fa4..0d2b4cb 100644
--- a/src/scripts/compile-arb.ts
+++ b/src/scripts/compile-arb.ts
@@ -2,6 +2,8 @@
import fs from 'fs'
import path from 'path'
import readline from 'readline'
+import type { ICUASTNode } from '@/src'
+import { ICUUtil } from '@/src'
/* ------------------ types ------------------ */
@@ -26,19 +28,10 @@ interface FuncParam {
typing: string,
}
-interface TextEntry {
- type: 'text',
- value: string,
-}
-
-interface FuncEntry {
- type: 'func',
- params: FuncParam[],
- value: string,
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type TranslationEntry = TextEntry | FuncEntry | Record
+type TranslationEntry =
+ { type: 'text', value: string }
+ | { type: 'func', params: FuncParam[], value: string }
+ | { type: 'nested', value: Record }
/* ------------------ CLI args ------------------ */
function parseArgs() {
@@ -134,10 +127,6 @@ if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
-function toSingleQuote(str: string): string {
- return `'${str.replace(/'/g, "\\'")}'`
-}
-
const locales = new Set()
/* ------------------ ARB reader ------------------ */
@@ -177,8 +166,8 @@ function readARBDir(
const meta = content[`@${key}`] as ARBMeta | undefined
const flatKey = prefix ? `${prefix}.${key}` : key
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const entryObj: any = {}
+
+ let entryObj: TranslationEntry
if (meta?.placeholders) {
// ICU function
@@ -198,15 +187,16 @@ function readARBDir(
}
)
- entryObj.type = 'func'
- entryObj.params = params
- entryObj.value = `(values): string => ICUUtil.interpret(${toSingleQuote(
- String(value)
- )}, values)`
+ entryObj = {
+ type: 'func',
+ params,
+ value: value as string,
+ }
} else {
- // plain text
- entryObj.type = 'text'
- entryObj.value = value as string
+ entryObj = {
+ type: 'text',
+ value: value as string
+ }
}
result[locale][flatKey] = entryObj
@@ -218,6 +208,89 @@ function readARBDir(
/* ------------------ 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
@@ -233,17 +306,31 @@ function generateCode(
const isLast = entries[entries.length - 1][0] === key
const comma = isLast ? '' : ','
- if ((entry as FuncEntry).type === 'func') {
- str += `${indent}${quotedKey}: ${(entry as FuncEntry).value}${comma}\n`
- } else if ((entry as TextEntry).type === 'text') {
- str += `${indent}${quotedKey}: ${toSingleQuote(
- (entry as TextEntry).value
- )}${comma}\n`
+ 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`
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- str += generateCode(entry as any, indentLevel + 1)
+ str += generateCode(entry.value, indentLevel + 1)
str += `${indent}}${comma}\n`
}
}
@@ -281,12 +368,12 @@ function generateType(
for (const [key, entry] of Object.entries(fullObject)) {
const quotedKey = `'${key}'`
- if ((entry as FuncEntry).type === 'func') {
- const params = (entry as FuncEntry).params
+ 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 as TextEntry).type === 'text') {
+ } else if (entry.type === 'text') {
str += `${indent}${quotedKey}: string,\n`
}
}
@@ -299,8 +386,11 @@ function generateType(
async function main(): Promise {
const translationData = readARBDir(inputDir)
- let output = `// AUTO-GENERATED. DO NOT EDIT.\n\n`
- output += `import { ICUUtil, Translation } from '@helpwave/internationalization'\n\n`
+ 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()
@@ -310,13 +400,17 @@ async function main(): Promise {
output += `export type SupportedLocale = typeof supportedLocales[number]\n\n`
- output += `export type GeneratedTranslationEntries = {\n${generateType(
- translationData
- )}}\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] }
+ }
+
- output += `export const generatedTranslations: Translation> = {\n${generateCode(
- translationData
- )}}\n\n`
+ const generatedTranslation = generateCode(value)
+ output += `export const generatedTranslations: Translation> = {\n${generatedTranslation}}\n\n`
if (fs.existsSync(OUTPUT_FILE) && !force) {
const answer = await askQuestion(
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,
+}