From 92aab9e25c399d2dcfa845757503bcef6d830d46 Mon Sep 17 00:00:00 2001 From: Sergio Brighenti Date: Thu, 14 Dec 2023 13:00:39 +0100 Subject: [PATCH] Improved pluralizer better tests --- .github/workflows/tests.yaml | 27 +++ package.json | 2 +- src/exporter.ts | 10 +- src/index.ts | 19 +- src/pluralizer.ts | 357 +++++++++++++++++++++++++++++++++++ src/translator.ts | 92 ++------- src/vite.ts | 5 + tests/exporter.test.ts | 42 ++--- tests/trans.test.ts | 25 ++- 9 files changed, 474 insertions(+), 105 deletions(-) create mode 100644 .github/workflows/tests.yaml create mode 100644 src/pluralizer.ts diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..a99abaf --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,27 @@ +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [ 18.x ] + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + + - name: Npm install + run: npm ci + + - name: Execute tests + run: npm run tests \ No newline at end of file diff --git a/package.json b/package.json index 527fb39..910ae34 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Laravel localization bridge for your frontend.", "main": "index.ts", "scripts": { - "test": "vitest" + "tests": "vitest --run" }, "repository": { "type": "git", diff --git a/src/exporter.ts b/src/exporter.ts index 1157426..388ff95 100644 --- a/src/exporter.ts +++ b/src/exporter.ts @@ -7,7 +7,7 @@ interface CandidateTranslation { type: 'php' | 'json' basePath: string path: string - name: string + name: string | null nesting: string[] locale: string } @@ -53,7 +53,11 @@ export const exportTranslations = (...paths: string[]) => { current = current[nest] }) - current[candidate.name] = content + if (candidate.name) { + current[candidate.name] = content + } else { + translations[candidate.locale][candidate.type] = {...current, ...content} + } }) return translations @@ -62,7 +66,7 @@ export const exportTranslations = (...paths: string[]) => { const getTranslationCandidates = (pattern: string, path: string, type: 'php' | 'json'): CandidateTranslation[] => { return glob.sync(pattern, {cwd: path}).map((transPath) => { const withoutExtension = transPath.split('.').shift() - const name = basename(withoutExtension).toLocaleLowerCase() + const name = type === 'php' ? basename(withoutExtension).toLocaleLowerCase() : null const locale = withoutExtension.split(sep).shift().toLocaleLowerCase() const nesting = withoutExtension.split(sep).slice(1, -1) return { diff --git a/src/index.ts b/src/index.ts index 7d6ee40..f80f8da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,13 +13,16 @@ const isServer = typeof window === 'undefined' const defaultConfig: Config = { locale: !isServer && document.documentElement.lang ? document.documentElement.lang.replace('-', '_') : 'en', - fallbackLocale: !isServer && window ? window?.fallbackLocale : null, + fallbackLocale: !isServer && window ? window?.fallbackLocale.replace('-', '_') : null, translations: translations, } const trans = (key: string, replace: object = {}, locale: string = null, config: Config = null) => { if (locale) { - (config ?? defaultConfig).locale = locale + if (!config) { + config = {...defaultConfig} + } + config.locale = locale } return translator(key, replace, false, config ?? defaultConfig) @@ -27,14 +30,22 @@ const trans = (key: string, replace: object = {}, locale: string = null, config: const transChoice = (key: string, number: number, replace: Object = {}, locale: string = null, config: Config = null) => { if (locale) { - (config ?? defaultConfig).locale = locale + if (!config) { + config = {...defaultConfig} + } + config.locale = locale } return translator(key, {...replace, count: number}, true, config ?? defaultConfig) } +const setLocale = (locale: string, fallbackLocale: string | null = null) => { + defaultConfig.locale = locale.replace('-', '_') + defaultConfig.fallbackLocale = fallbackLocale.replace('-', '_') +} + const __ = trans; const t = trans; const trans_choice = transChoice; -export {trans, __, t, transChoice, trans_choice} \ No newline at end of file +export {trans, __, t, transChoice, trans_choice, setLocale} \ No newline at end of file diff --git a/src/pluralizer.ts b/src/pluralizer.ts new file mode 100644 index 0000000..a86e192 --- /dev/null +++ b/src/pluralizer.ts @@ -0,0 +1,357 @@ +export function choose(message: string, number: number, lang: string): string { + let segments = message.split('|') + const extracted = extract(segments, number) + + if (extracted !== null) { + return extracted.trim() + } + + segments = stripConditions(segments) + const pluralIndex = getPluralIndex(lang, number) + + if (segments.length === 1 || !segments[pluralIndex]) { + return segments[0] + } + + return segments[pluralIndex] +} + +/** + * Extract a translation string using inline conditions. + */ +function extract(segments: string[], number: number): string | null { + for (const part of segments) { + let line = extractFromString(part, number) + + if (line !== null) { + return line + } + } + + return null +} + +/** + * Get the translation string if the condition matches. + */ +function extractFromString(part: string, number: number): string | null { + const matches = part.match(/^[\{\[]([^\[\]\{\}]*)[\}\]](.*)/s) || [] + if (matches.length !== 3) { + return null + } + + const condition = matches[1] + const value = matches[2] + + if (condition.includes(',')) { + let [from, to] = condition.split(',') + + if (to === '*' && number >= parseFloat(from)) { + return value + } else if (from === '*' && number <= parseFloat(to)) { + return value + } else if (number >= parseFloat(from) && number <= parseFloat(to)) { + return value + } + } + + return parseFloat(condition) === number ? value : null +} + +/** + * Strip the inline conditions from each segment, just leaving the text. + */ +function stripConditions(segments: string[]): string[] { + return segments.map((part) => part.replace(/^[\{\[]([^\[\]\{\}]*)[\}\]]/, '')) +} + +function getPluralIndex(lang: string, number: number): number { + switch (lang.replace('_', '-')) { + case 'af': + case 'af-ZA': + case 'bn': + case 'bn-BD': + case 'bn-IN': + case 'bg': + case 'bg-BG': + case 'ca': + case 'ca-AD': + case 'ca-ES': + case 'ca-FR': + case 'ca-IT': + case 'da': + case 'da-DK': + case 'de': + case 'de-AT': + case 'de-BE': + case 'de-CH': + case 'de-DE': + case 'de-LI': + case 'de-LU': + case 'el': + case 'el-CY': + case 'el-GR': + case 'en': + case 'en-AG': + case 'en-AU': + case 'en-BW': + case 'en-CA': + case 'en-DK': + case 'en-GB': + case 'en-HK': + case 'en-IE': + case 'en-IN': + case 'en-NG': + case 'en-NZ': + case 'en-PH': + case 'en-SG': + case 'en-US': + case 'en-ZA': + case 'en-ZM': + case 'en-ZW': + case 'eo': + case 'eo-US': + case 'es': + case 'es-AR': + case 'es-BO': + case 'es-CL': + case 'es-CO': + case 'es-CR': + case 'es-CU': + case 'es-DO': + case 'es-EC': + case 'es-ES': + case 'es-GT': + case 'es-HN': + case 'es-MX': + case 'es-NI': + case 'es-PA': + case 'es-PE': + case 'es-PR': + case 'es-PY': + case 'es-SV': + case 'es-US': + case 'es-UY': + case 'es-VE': + case 'et': + case 'et-EE': + case 'eu': + case 'eu-ES': + case 'eu-FR': + case 'fa': + case 'fa-IR': + case 'fi': + case 'fi-FI': + case 'fo': + case 'fo-FO': + case 'fur': + case 'fur-IT': + case 'fy': + case 'fy-DE': + case 'fy-NL': + case 'gl': + case 'gl-ES': + case 'gu': + case 'gu-IN': + case 'ha': + case 'ha-NG': + case 'he': + case 'he-IL': + case 'hu': + case 'hu-HU': + case 'is': + case 'is-IS': + case 'it': + case 'it-CH': + case 'it-IT': + case 'ku': + case 'ku-TR': + case 'lb': + case 'lb-LU': + case 'ml': + case 'ml-IN': + case 'mn': + case 'mn-MN': + case 'mr': + case 'mr-IN': + case 'nah': + case 'nb': + case 'nb-NO': + case 'ne': + case 'ne-NP': + case 'nl': + case 'nl-AW': + case 'nl-BE': + case 'nl-NL': + case 'nn': + case 'nn-NO': + case 'no': + case 'om': + case 'om-ET': + case 'om-KE': + case 'or': + case 'or-IN': + case 'pa': + case 'pa-IN': + case 'pa-PK': + case 'pap': + case 'pap-AN': + case 'pap-AW': + case 'pap-CW': + case 'ps': + case 'ps-AF': + case 'pt': + case 'pt-BR': + case 'pt-PT': + case 'so': + case 'so-DJ': + case 'so-ET': + case 'so-KE': + case 'so-SO': + case 'sq': + case 'sq-AL': + case 'sq-MK': + case 'sv': + case 'sv-FI': + case 'sv-SE': + case 'sw': + case 'sw-KE': + case 'sw-TZ': + case 'ta': + case 'ta-IN': + case 'ta-LK': + case 'te': + case 'te-IN': + case 'tk': + case 'tk-TM': + case 'ur': + case 'ur-IN': + case 'ur-PK': + case 'zu': + case 'zu-ZA': + return number === 1 ? 0 : 1 + case 'am': + case 'am-ET': + case 'bh': + case 'fil': + case 'fil-PH': + case 'fr': + case 'fr-BE': + case 'fr-CA': + case 'fr-CH': + case 'fr-FR': + case 'fr-LU': + case 'gun': + case 'hi': + case 'hi-IN': + case 'hy': + case 'hy-AM': + case 'ln': + case 'ln-CD': + case 'mg': + case 'mg-MG': + case 'nso': + case 'nso-ZA': + case 'ti': + case 'ti-ER': + case 'ti-ET': + case 'wa': + case 'wa-BE': + case 'xbr': + return number === 0 || number === 1 ? 0 : 1 + case 'be': + case 'be-BY': + case 'bs': + case 'bs-BA': + case 'hr': + case 'hr-HR': + case 'ru': + case 'ru-RU': + case 'ru-UA': + case 'sr': + case 'sr-ME': + case 'sr-RS': + case 'uk': + case 'uk-UA': + return number % 10 == 1 && number % 100 != 11 + ? 0 + : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20) + ? 1 + : 2 + case 'cs': + case 'cs-CZ': + case 'sk': + case 'sk-SK': + return number == 1 ? 0 : number >= 2 && number <= 4 ? 1 : 2 + case 'ga': + case 'ga-IE': + return number == 1 ? 0 : number == 2 ? 1 : 2 + case 'lt': + case 'lt-LT': + return number % 10 == 1 && number % 100 != 11 + ? 0 + : number % 10 >= 2 && (number % 100 < 10 || number % 100 >= 20) + ? 1 + : 2 + case 'sl': + case 'sl-SI': + return number % 100 == 1 ? 0 : number % 100 == 2 ? 1 : number % 100 == 3 || number % 100 == 4 ? 2 : 3 + case 'mk': + case 'mk-MK': + return number % 10 == 1 ? 0 : 1 + case 'mt': + case 'mt-MT': + return number == 1 + ? 0 + : number == 0 || (number % 100 > 1 && number % 100 < 11) + ? 1 + : number % 100 > 10 && number % 100 < 20 + ? 2 + : 3 + case 'lv': + case 'lv-LV': + return number == 0 ? 0 : number % 10 == 1 && number % 100 != 11 ? 1 : 2 + case 'pl': + case 'pl-PL': + return number == 1 ? 0 : number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14) ? 1 : 2 + case 'cy': + case 'cy-GB': + return number == 1 ? 0 : number == 2 ? 1 : number == 8 || number == 11 ? 2 : 3 + case 'ro': + case 'ro-RO': + return number == 1 ? 0 : number == 0 || (number % 100 > 0 && number % 100 < 20) ? 1 : 2 + case 'ar': + case 'ar-AE': + case 'ar-BH': + case 'ar-DZ': + case 'ar-EG': + case 'ar-IN': + case 'ar-IQ': + case 'ar-JO': + case 'ar-KW': + case 'ar-LB': + case 'ar-LY': + case 'ar-MA': + case 'ar-OM': + case 'ar-QA': + case 'ar-SA': + case 'ar-SD': + case 'ar-SS': + case 'ar-SY': + case 'ar-TN': + case 'ar-YE': + return number == 0 + ? 0 + : number == 1 + ? 1 + : number == 2 + ? 2 + : number % 100 >= 3 && number % 100 <= 10 + ? 3 + : number % 100 >= 11 && number % 100 <= 99 + ? 4 + : 5 + default: + return 0 + } +} \ No newline at end of file diff --git a/src/translator.ts b/src/translator.ts index 1e39aa7..1d7d0d9 100644 --- a/src/translator.ts +++ b/src/translator.ts @@ -1,3 +1,5 @@ +import {choose} from "./pluralizer"; + export interface Config { locale: string fallbackLocale: string @@ -16,17 +18,25 @@ export const translator = (key: string, replace: object, pluralize: boolean, con translation = getTranslation(key, fallbackLocale, config.translations) } - return translate(translation ?? key, replace, pluralize) + return translate(translation ?? key, replace, locale, pluralize) } const getTranslation = (key: string, locale: string, translations: object) => { + let translation = null + + // Try to get the translation from the php array try { - return key + translation = key .split('.') .reduce((t, i) => t[i] || null, translations[locale].php) } catch (e) { } + if (translation) { + return translation + } + + // Try to get the translation from the json array try { return key .split('.') @@ -34,84 +44,18 @@ const getTranslation = (key: string, locale: string, translations: object) => { } catch (e) { } - return null + return translation } -const translate = (translation: string, replace: object = {}, shouldPluralize: boolean = false) => { - let translated = shouldPluralize ? pluralize(translation, replace['count']) : translation - if (typeof replace === 'undefined') { - return translation +const translate = (translation: string | object, replace: object = {}, locale: string, shouldPluralize: boolean = false) => { + if (shouldPluralize && typeof translation === 'string') { + translation = choose(translation, replace['count'], locale); } Object.keys(replace).forEach(key => { const value = replace[key] - translated = translated.toString().replace(':' + key, value) + translation = translation.toString().replace(':' + key, value) }) - return translated + return translation } - -const stripConditions = (sentence: string) => { - return sentence.replace(/^[\{\[]([^\[\]\{\}]*)[\}\]]/, '') -} - -const pluralize = (sentence: string, count: number) => { - let parts = sentence.split('|') - - //Get SOLO number pattern parts - const soloPattern = /{(?\d+\.?\d*)}[^\|]*/g - const soloParts = parts.map(part => { - let matched = part.matchAll(soloPattern).next().value - if (!matched) { - return; - } - return { - count: 1 * matched[1], - value: stripConditions(matched[0]).trim() - } - }).filter((o) => o !== undefined) - let i = 0; - //Loop through the solo parts - while (i < soloParts.length) { - const p = soloParts[i] - if (p.count === count) { - return p.value - } - i++; - } - - //Get ranged pattern parts - const rangedPattern = /\[(?\d+|\*),(?\d+|\*)][^\|]*/g - const rangedParts = parts.map(part => { - let matched = part.matchAll(rangedPattern).next().value - if (!matched) { - return; - } - return { - start: parseInt(matched[1]), - end: parseInt(matched[2]) || -1, - value: matched[0].replace(`[${matched[1]},${matched[2]}]`, '').trim() - } - }).filter((o) => o !== undefined) - - i = 0; - //Loop through the solo parts - while (i < rangedParts.length) { - const p = rangedParts[i] - - if (count >= p.start || isNaN(p.start)) { - if (p.end < 0 || count <= p.end) { - return p.value - } - } - - i++; - } - - if (parts.length > 1) { - const index = count == 1 ? 0 : 1; - return stripConditions(parts[index] ?? parts[0]).trim() - } - - return sentence -} \ No newline at end of file diff --git a/src/vite.ts b/src/vite.ts index 5646616..ad3da68 100644 --- a/src/vite.ts +++ b/src/vite.ts @@ -34,5 +34,10 @@ export default function laravelTranslator(options: string | VitePluginOptionsInt return `export default ${JSON.stringify(translations)}` } }, + handleHotUpdate(ctx) { + if (ctx.file === resolvedVirtualModuleId) { + translations = exportTranslations(frameworkLangPath, langPath, ...additionalLangPaths) + } + } } } \ No newline at end of file diff --git a/tests/exporter.test.ts b/tests/exporter.test.ts index f1a36f3..e44a0f4 100644 --- a/tests/exporter.test.ts +++ b/tests/exporter.test.ts @@ -30,16 +30,15 @@ test('exports complex locale', async () => { }, "nested": {"cars": {"car": {"is_electric": "É elétrico?", "foo": {"level1": {"level2": "barpt"}}}}} }, "json": { - "pt": { - "Welcome!": "Bem-vindo!", - "Welcome, :name!": "Bem-vindo, :name!", - "hi :name, hi :name": "olá :name, olá :name", - "{1} :count minute ago|[2,*] :count minutes ago": "{1} há :count minuto|[2,*] há :count minutos", - "foo.bar": "baz", - "Start/end": "Início/Fim", - "Get started.": "Comece.", - "
Welcome
": "
Bem-vindo
" - } + "Welcome!": "Bem-vindo!", + "Welcome, :name!": "Bem-vindo, :name!", + "hi :name, hi :name": "olá :name, olá :name", + "{1} :count minute ago|[2,*] :count minutes ago": "{1} há :count minuto|[2,*] há :count minutos", + "foo.bar": "baz", + "Start/end": "Início/Fim", + "Get started.": "Comece.", + "
Welcome
": "
Bem-vindo
" + } }, "fr": {"php": {"auth": {"failed": "Ces identifiants ne correspondent pas à nos enregistrements."}}}, @@ -68,20 +67,19 @@ test('exports complex locale', async () => { } }, "json": { - "en": { - "Welcome!": "Wecome!", - "Welcome, :name!": "Welcome, :name!", - "Only Available on EN": "Only Available on EN", - "{1} :count minute ago|[2,*] :count minutes ago": "{1} :count minute ago|[2,*] :count minutes ago", - "Start/end": "Start/End", - "Get started.": "Get started.", - "English only.": "English only." - } + "Welcome!": "Wecome!", + "Welcome, :name!": "Welcome, :name!", + "Only Available on EN": "Only Available on EN", + "{1} :count minute ago|[2,*] :count minutes ago": "{1} :count minute ago|[2,*] :count minutes ago", + "Start/end": "Start/End", + "Get started.": "Get started.", + "English only.": "English only." + } }, - "zh_tw": {"json": {"zh_tw": {"Welcome!": "歡迎"}}}, - "es": {"json": {"es": {"Welcome!": "Bienvenido!"}}}, - "de": {"json": {"de": {"auth.arr.0": "foo", "auth.arr.1": "bar"}}} + "zh_tw": {"json": {"Welcome!": "歡迎"}}, + "es": {"json": {"Welcome!": "Bienvenido!"}}, + "de": {"json": {"auth.arr.0": "foo", "auth.arr.1": "bar"}} } ) }) \ No newline at end of file diff --git a/tests/trans.test.ts b/tests/trans.test.ts index 5c1388b..9a2dfc2 100644 --- a/tests/trans.test.ts +++ b/tests/trans.test.ts @@ -1,5 +1,5 @@ import {expect, test} from "vitest"; -import {trans} from "../src"; +import {trans, trans_choice} from "../src"; test('trans works with random key', async () => { const r = trans('random.key') @@ -19,5 +19,28 @@ test('trans works with a key that exists nested', async () => { expect(r).toBe('Electric') }) +test('trans works with parameters', async () => { + const r = trans('Welcome, :name!', {name: 'John'}) + + expect(r).toBe('Welcome, John!') +}) + +test('trans works specifying locale', async () => { + const r = trans('Welcome, :name!', {name: 'John'}, 'pt') + + expect(r).toBe('Bem-vindo, John!') +}) + +test('trans choice works with solo', async () => { + const r = trans_choice('domain.car.car', 1) + + expect(r).toBe('Car') +}) + +test('trans choice works with multi', async () => { + const r = trans_choice('domain.car.car', 2) + + expect(r).toBe('Cars') +})