diff --git a/lib/gettext.ts b/lib/gettext.ts index 20db50f6..ec17e3ed 100644 --- a/lib/gettext.ts +++ b/lib/gettext.ts @@ -1,56 +1,54 @@ /** * This module provides functionality to translate applications independent from Nextcloud - * + * * @packageDocumentation * @module @nextcloud/l10n/gettext * @example * ```js import { getGettextBuilder } from '@nextcloud/l10n/gettext' - const gt = getGettextBuilder() - .detectLocale() // or use setLanguage() - .addTranslation(/* ... *\/) - .build() - + .detectLocale() // or use setLanguage() + .addTranslation(/* ... *\/) + .build() gt.gettext('some string to translate') ``` */ -import GetText from "node-gettext" +import GetText from 'node-gettext' -import { getLanguage } from "." +import { getLanguage } from '.' /** * @notExported */ class GettextBuilder { - private locale?: string - private translations = {} - private debug = false + private locale?: string + private translations = {} + private debug = false - setLanguage(language: string): GettextBuilder { - this.locale = language - return this - } + setLanguage(language: string): GettextBuilder { + this.locale = language + return this + } - /** Try to detect locale from context with `en` as fallback value */ - detectLocale(): GettextBuilder { - return this.setLanguage(getLanguage().replace('-', '_')) - } + /** Try to detect locale from context with `en` as fallback value */ + detectLocale(): GettextBuilder { + return this.setLanguage(getLanguage().replace('-', '_')) + } - addTranslation(language: string, data: any): GettextBuilder { - this.translations[language] = data - return this - } + addTranslation(language: string, data: object): GettextBuilder { + this.translations[language] = data + return this + } - enableDebugMode(): GettextBuilder { - this.debug = true - return this - } + enableDebugMode(): GettextBuilder { + this.debug = true + return this + } - build(): GettextWrapper { - return new GettextWrapper(this.locale || 'en', this.translations, this.debug) - } + build(): GettextWrapper { + return new GettextWrapper(this.locale || 'en', this.translations, this.debug) + } } @@ -59,50 +57,65 @@ class GettextBuilder { */ class GettextWrapper { - private gt: GetText - - constructor(locale: string, data: any, debug: boolean) { - this.gt = new GetText({ - debug, - sourceLocale: 'en', - }) - - for (let key in data) { - this.gt.addTranslations(key, 'messages', data[key]) - } - - this.gt.setLocale(locale) - } - - private subtitudePlaceholders(translated: string, vars: Record): string { - return translated.replace(/{([^{}]*)}/g, (a, b) => { - const r = vars[b] - if (typeof r === 'string' || typeof r === 'number') { - return r.toString() - } else { - return a - } - }) - } - - /** Get translated string (singular form), optionally with placeholders */ - gettext(original: string, placeholders: Record = {}): string { - return this.subtitudePlaceholders( - this.gt.gettext(original), - placeholders - ) - } - - /** Get translated string with plural forms */ - ngettext(singular: string, plural: string, count: number, placeholders: Record = {}): string { - return this.subtitudePlaceholders( - this.gt.ngettext(singular, plural, count).replace(/%n/g, count.toString()), - placeholders - ) - } + private gt: GetText + + constructor(locale: string, data: object, debug: boolean) { + this.gt = new GetText({ + debug, + sourceLocale: 'en', + }) + + for (const key in data) { + this.gt.addTranslations(key, 'messages', data[key]) + } + + this.gt.setLocale(locale) + } + + private subtitudePlaceholders(translated: string, vars: Record): string { + return translated.replace(/{([^{}]*)}/g, (a, b) => { + const r = vars[b] + if (typeof r === 'string' || typeof r === 'number') { + return r.toString() + } else { + return a + } + }) + } + + /** + * Get translated string (singular form), optionally with placeholders + * + * @param original original string to translate + * @param placeholders map of placeholder key to value + */ + gettext(original: string, placeholders: Record = {}): string { + return this.subtitudePlaceholders( + this.gt.gettext(original), + placeholders + ) + } + + /** + * Get translated string with plural forms + * + * @param singular Singular text form + * @param plural Plural text form to be used if `count` requires it + * @param count The number to insert into the text + * @param placeholders optional map of placeholder key to value + */ + ngettext(singular: string, plural: string, count: number, placeholders: Record = {}): string { + return this.subtitudePlaceholders( + this.gt.ngettext(singular, plural, count).replace(/%n/g, count.toString()), + placeholders + ) + } } +/** + * Create a new GettextBuilder instance + */ export function getGettextBuilder() { - return new GettextBuilder() + return new GettextBuilder() } diff --git a/tests/date.test.ts b/tests/date.test.ts index fec80864..cac55786 100644 --- a/tests/date.test.ts +++ b/tests/date.test.ts @@ -1,111 +1,112 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ /// -declare var window: Nextcloud.v24.WindowWithGlobals - import { getDayNames, getDayNamesMin, getDayNamesShort, getFirstDay, getMonthNames, getMonthNamesShort } from '../lib/date' +declare let window: Nextcloud.v24.WindowWithGlobals + describe('date', () => { - const orginalWarn = console.warn - - afterAll(() => { - console.warn = orginalWarn - }) - beforeEach(() => { - console.warn = jest.fn() - }) - - describe('getFirstDay', () => { - // @ts-ignore - afterAll(() => { delete window.firstDay }) - - it('works without `OC`', () => { - expect(getFirstDay()).toBe(1) - // Warning as fallback is being used - expect(console.warn).toBeCalled() - }) - - it('works with `OC`', () => { - window.firstDay = 3 - expect(getFirstDay()).toBe(3) - }) - }) - - describe('getDayNames', () => { - // @ts-ignore - afterAll(() => { delete window.dayNames }) - - it('works without `OC`', () => { - expect(getDayNames().length).toBe(7) - // Warning as fallback is being used - expect(console.warn).toBeCalled() - }) - - it('works with `OC`', () => { - window.dayNames = 'a'.repeat(7).split('') - expect(getDayNames()).toBe(window.dayNames) - }) - }) - - describe('getDayNamesShort', () => { - // @ts-ignore - afterAll(() => { delete window.dayNamesShort }) - - it('works without `OC`', () => { - expect(getDayNamesShort().length).toBe(7) - // Warning as fallback is being used - expect(console.warn).toBeCalled() - }) - - it('works with `OC`', () => { - window.dayNamesShort = 'b'.repeat(7).split('') - expect(getDayNamesShort()).toBe(window.dayNamesShort) - }) - }) - - describe('getDayNamesMin', () => { - // @ts-ignore - afterAll(() => { delete window.dayNamesMin }) - - it('works without `OC`', () => { - expect(getDayNamesMin().length).toBe(7) - // Warning as fallback is being used - expect(console.warn).toBeCalled() - }) - - it('works with `OC`', () => { - window.dayNamesMin = 'c'.repeat(7).split('') - expect(getDayNamesMin()).toBe(window.dayNamesMin) - }) - }) - - describe('getMonthNames', () => { - // @ts-ignore - afterAll(() => { delete window.monthNames }) - - it('works without `OC`', () => { - expect(getMonthNames().length).toBe(12) - // Warning as fallback is being used - expect(console.warn).toBeCalled() - }) - - it('works with `OC`', () => { - window.monthNames = 'd'.repeat(12).split('') - expect(getMonthNames()).toBe(window.monthNames) - }) - }) - - describe('getMonthNamesShort', () => { - // @ts-ignore - afterAll(() => { delete window.monthNamesShort }) - - it('works without `OC`', () => { - expect(getMonthNamesShort().length).toBe(12) - // Warning as fallback is being used - expect(console.warn).toBeCalled() - }) - - it('works with `OC`', () => { - window.monthNamesShort = 'e'.repeat(12).split('') - expect(getMonthNamesShort()).toBe(window.monthNamesShort) - }) - }) + const orginalWarn = console.warn + + afterAll(() => { + console.warn = orginalWarn + }) + beforeEach(() => { + console.warn = jest.fn() + }) + + describe('getFirstDay', () => { + // @ts-ignore + afterAll(() => { delete window.firstDay }) + + it('works without `OC`', () => { + expect(getFirstDay()).toBe(1) + // Warning as fallback is being used + expect(console.warn).toBeCalled() + }) + + it('works with `OC`', () => { + window.firstDay = 3 + expect(getFirstDay()).toBe(3) + }) + }) + + describe('getDayNames', () => { + // @ts-ignore + afterAll(() => { delete window.dayNames }) + + it('works without `OC`', () => { + expect(getDayNames().length).toBe(7) + // Warning as fallback is being used + expect(console.warn).toBeCalled() + }) + + it('works with `OC`', () => { + window.dayNames = 'a'.repeat(7).split('') + expect(getDayNames()).toBe(window.dayNames) + }) + }) + + describe('getDayNamesShort', () => { + // @ts-ignore + afterAll(() => { delete window.dayNamesShort }) + + it('works without `OC`', () => { + expect(getDayNamesShort().length).toBe(7) + // Warning as fallback is being used + expect(console.warn).toBeCalled() + }) + + it('works with `OC`', () => { + window.dayNamesShort = 'b'.repeat(7).split('') + expect(getDayNamesShort()).toBe(window.dayNamesShort) + }) + }) + + describe('getDayNamesMin', () => { + // @ts-ignore + afterAll(() => { delete window.dayNamesMin }) + + it('works without `OC`', () => { + expect(getDayNamesMin().length).toBe(7) + // Warning as fallback is being used + expect(console.warn).toBeCalled() + }) + + it('works with `OC`', () => { + window.dayNamesMin = 'c'.repeat(7).split('') + expect(getDayNamesMin()).toBe(window.dayNamesMin) + }) + }) + + describe('getMonthNames', () => { + // @ts-ignore + afterAll(() => { delete window.monthNames }) + + it('works without `OC`', () => { + expect(getMonthNames().length).toBe(12) + // Warning as fallback is being used + expect(console.warn).toBeCalled() + }) + + it('works with `OC`', () => { + window.monthNames = 'd'.repeat(12).split('') + expect(getMonthNames()).toBe(window.monthNames) + }) + }) + + describe('getMonthNamesShort', () => { + // @ts-ignore + afterAll(() => { delete window.monthNamesShort }) + + it('works without `OC`', () => { + expect(getMonthNamesShort().length).toBe(12) + // Warning as fallback is being used + expect(console.warn).toBeCalled() + }) + + it('works with `OC`', () => { + window.monthNamesShort = 'e'.repeat(12).split('') + expect(getMonthNamesShort()).toBe(window.monthNamesShort) + }) + }) }) diff --git a/tests/gettext.test.js b/tests/gettext.test.js index 6bf7631d..e4d076c5 100644 --- a/tests/gettext.test.js +++ b/tests/gettext.test.js @@ -1,118 +1,118 @@ import { po } from 'gettext-parser' -import { getGettextBuilder } from '../lib/gettext' +import { getGettextBuilder } from '../lib/gettext.ts' describe('gettext', () => { - beforeEach(() => { - jest.spyOn(console, 'warn') - }) + beforeEach(() => { + jest.spyOn(console, 'warn') + }) - afterEach(() => { - console.warn.mockRestore() - }) + afterEach(() => { + console.warn.mockRestore() + }) - it('falls back to the original string', () => { - const gt = getGettextBuilder() - .setLanguage('de') - .build() + it('falls back to the original string', () => { + const gt = getGettextBuilder() + .setLanguage('de') + .build() - const translation = gt.gettext('Settings') + const translation = gt.gettext('Settings') - expect(translation).toEqual('Settings') - }) + expect(translation).toEqual('Settings') + }) - it('does not log in production', () => { - const gt = getGettextBuilder() - .setLanguage('de') - .build() + it('does not log in production', () => { + const gt = getGettextBuilder() + .setLanguage('de') + .build() - gt.gettext('Settings') + gt.gettext('Settings') - expect(console.warn).not.toHaveBeenCalled() - }) + expect(console.warn).not.toHaveBeenCalled() + }) - it('has optional debug logs', () => { - const gt = getGettextBuilder() - .setLanguage('de') - .enableDebugMode() - .build() + it('has optional debug logs', () => { + const gt = getGettextBuilder() + .setLanguage('de') + .enableDebugMode() + .build() - gt.gettext('Settings') + gt.gettext('Settings') - expect(console.warn).toHaveBeenCalled() - }) + expect(console.warn).toHaveBeenCalled() + }) - it('falls back to the original singular string', () => { - const gt = getGettextBuilder() - .setLanguage('en') - .build() + it('falls back to the original singular string', () => { + const gt = getGettextBuilder() + .setLanguage('en') + .build() - const translated = gt.ngettext('%n Setting', '%n Settings', 1) + const translated = gt.ngettext('%n Setting', '%n Settings', 1) - expect(translated).toEqual('1 Setting') - }) + expect(translated).toEqual('1 Setting') + }) - it('falls back to the original plural string', () => { - const gt = getGettextBuilder() - .setLanguage('en') - .build() + it('falls back to the original plural string', () => { + const gt = getGettextBuilder() + .setLanguage('en') + .build() - const translated = gt.ngettext('%n Setting', '%n Settings', 2) + const translated = gt.ngettext('%n Setting', '%n Settings', 2) - expect(translated).toEqual('2 Settings') - }) + expect(translated).toEqual('2 Settings') + }) - it('detects en as default locale/language', () => { - const detected = getGettextBuilder() - .detectLocale() - .build() + it('detects en as default locale/language', () => { + const detected = getGettextBuilder() + .detectLocale() + .build() - const manual = getGettextBuilder() - .setLanguage('en') - .build() + const manual = getGettextBuilder() + .setLanguage('en') + .build() - expect(detected).toEqual(manual) - }) + expect(detected).toEqual(manual) + }) - it('used nextcloud-style placeholder replacement', () => { - const gt = getGettextBuilder() - .setLanguage('de') - .build() + it('used nextcloud-style placeholder replacement', () => { + const gt = getGettextBuilder() + .setLanguage('de') + .build() - const translation = gt.gettext('I wish Nextcloud were written in {lang}', { - lang: 'Rust' - }) + const translation = gt.gettext('I wish Nextcloud were written in {lang}', { + lang: 'Rust', + }) - expect(translation).toEqual('I wish Nextcloud were written in Rust') - }) + expect(translation).toEqual('I wish Nextcloud were written in Rust') + }) - it('is fault tolerant to invalid placeholders', () => { - const gt = getGettextBuilder() - .setLanguage('de') - .build() + it('is fault tolerant to invalid placeholders', () => { + const gt = getGettextBuilder() + .setLanguage('de') + .build() - const translation = gt.gettext('This is {value}', { - value: false - }) + const translation = gt.gettext('This is {value}', { + value: false, + }) - expect(translation).toEqual('This is {value}') - }) + expect(translation).toEqual('This is {value}') + }) - it('used nextcloud-style placeholder replacement for plurals', () => { - const gt = getGettextBuilder() - .setLanguage('de') - .build() + it('used nextcloud-style placeholder replacement for plurals', () => { + const gt = getGettextBuilder() + .setLanguage('de') + .build() - const translation = gt.ngettext('%n {what} Setting', '%n {what} Settings', 2, { - what: 'test', - }) + const translation = gt.ngettext('%n {what} Setting', '%n {what} Settings', 2, { + what: 'test', + }) - expect(translation).toEqual('2 test Settings') - }) + expect(translation).toEqual('2 test Settings') + }) - it('translates', () => { - const pot = `msgid "" + it('translates', () => { + const pot = `msgid "" msgstr "" "Last-Translator: Translator, 2020\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -122,19 +122,19 @@ msgstr "" msgid "abc" msgstr "def" ` - const gt = getGettextBuilder() - .setLanguage('sv') - .addTranslation('sv', po.parse(pot)) - .build() + const gt = getGettextBuilder() + .setLanguage('sv') + .addTranslation('sv', po.parse(pot)) + .build() - const translation = gt.gettext('abc') + const translation = gt.gettext('abc') - expect(translation).toEqual('def') - }) + expect(translation).toEqual('def') + }) - it('translates plurals', () => { - // From https://www.gnu.org/software/gettext/manual/html_node/Translating-plural-forms.html - const pot = `msgid "" + it('translates plurals', () => { + // From https://www.gnu.org/software/gettext/manual/html_node/Translating-plural-forms.html + const pot = `msgid "" msgstr "" "Last-Translator: Translator, 2020\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -147,18 +147,18 @@ msgstr[0] "%n slika uklonjenih" msgstr[1] "%n slika uklonjenih" msgstr[2] "%n slika uklonjenih" ` - const gt = getGettextBuilder() - .setLanguage('sv') - .addTranslation('sv', po.parse(pot)) - .build() + const gt = getGettextBuilder() + .setLanguage('sv') + .addTranslation('sv', po.parse(pot)) + .build() - const translation = gt.ngettext('One file removed', '%n files removed', 2) + const translation = gt.ngettext('One file removed', '%n files removed', 2) - expect(translation).toEqual('2 slika uklonjenih') - }) + expect(translation).toEqual('2 slika uklonjenih') + }) - it('falls back to english', () => { - const pot = `msgid "" + it('falls back to english', () => { + const pot = `msgid "" msgstr "" "Last-Translator: Translator, 2023\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -168,24 +168,24 @@ msgstr "" msgid "abc" msgstr "xyz" ` - // Do not set local explicitly, so 'en' should be used - const gt = getGettextBuilder() - .addTranslation('en', po.parse(pot)) - .build() + // Do not set local explicitly, so 'en' should be used + const gt = getGettextBuilder() + .addTranslation('en', po.parse(pot)) + .build() - const translation = gt.gettext('abc') + const translation = gt.gettext('abc') - expect(translation).toEqual('xyz') - }) + expect(translation).toEqual('xyz') + }) - it('does not escape special chars', () => { - const gt = getGettextBuilder() - .setLanguage('de') - .build() + it('does not escape special chars', () => { + const gt = getGettextBuilder() + .setLanguage('de') + .build() - const translation = gt.gettext('test & stuff') + const translation = gt.gettext('test & stuff') - expect(translation).toEqual('test & stuff') - }) + expect(translation).toEqual('test & stuff') + }) }) diff --git a/tests/index.test.js b/tests/index.test.js index 527ad8ad..e1db63ed 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -1,86 +1,80 @@ import { - getCanonicalLocale, - getFirstDay, - getDayNames, - getDayNamesShort, - getDayNamesMin, - getMonthNames, - getMonthNamesShort, - translate, - translatePlural + getCanonicalLocale, + translate, + translatePlural, } from '../lib/index' const setLocale = (locale) => document.documentElement.setAttribute('data-locale', locale) describe('translate', () => { - const mockWindowDE = () => { - window._oc_l10n_registry_translations = { - core: { - 'Hello world!': 'Hallo Welt!', - 'Hello {name}': 'Hallo {name}', - '_download %n file_::_download %n files_': [ - 'Lade %n Datei herunter', - 'Lade %n Dateien herunter' - ], - } - } - window._oc_l10n_registry_plural_functions = { - core: (t) => 1 === t ? 0 : 1 - } - setLocale('de') - } + const mockWindowDE = () => { + window._oc_l10n_registry_translations = { + core: { + 'Hello world!': 'Hallo Welt!', + 'Hello {name}': 'Hallo {name}', + '_download %n file_::_download %n files_': [ + 'Lade %n Datei herunter', + 'Lade %n Dateien herunter', + ], + }, + } + window._oc_l10n_registry_plural_functions = { + core: (t) => t === 1 ? 0 : 1, + } + setLocale('de') + } - beforeAll(mockWindowDE) + beforeAll(mockWindowDE) - it('singular', () => { - const text = 'Hello world!' - const translation = translate('core', text) - expect(translation).toBe('Hallo Welt!') - }) + it('singular', () => { + const text = 'Hello world!' + const translation = translate('core', text) + expect(translation).toBe('Hallo Welt!') + }) - it('with variable', () => { - const text = 'Hello {name}' - const translation = translate('core', text, {name: 'J. Doe'}) - expect(translation).toBe('Hallo J. Doe') - }) + it('with variable', () => { + const text = 'Hello {name}' + const translation = translate('core', text, { name: 'J. Doe' }) + expect(translation).toBe('Hallo J. Doe') + }) - it('plural', () => { - const text = ['download %n file', 'download %n files'] + it('plural', () => { + const text = ['download %n file', 'download %n files'] - expect(translatePlural('core', ...text, 1)).toBe('Lade 1 Datei herunter') + expect(translatePlural('core', ...text, 1)).toBe('Lade 1 Datei herunter') - expect(translatePlural('core', ...text, 2)).toBe('Lade 2 Dateien herunter') - }) + expect(translatePlural('core', ...text, 2)).toBe('Lade 2 Dateien herunter') + }) - it('missing text', () => { - const text = 'Good bye!' - const translation = translate('core', text) - expect(translation).toBe('Good bye!') - }) + it('missing text', () => { + const text = 'Good bye!' + const translation = translate('core', text) + expect(translation).toBe('Good bye!') + }) - it('missing application', () => { - const text = 'Good bye!' - const translation = translate('unavailable', text) - expect(translation).toBe('Good bye!') - }) + it('missing application', () => { + const text = 'Good bye!' + const translation = translate('unavailable', text) + expect(translation).toBe('Good bye!') + }) }) describe('getCanonicalLocale', () => { - afterEach(() => { - setLocale('') - }) + afterEach(() => { + setLocale('') + }) - it('Returns primary locales as is', () => { - setLocale('de') - expect(getCanonicalLocale()).toEqual('de') - setLocale('zu') - expect(getCanonicalLocale()).toEqual('zu') - }) + it('Returns primary locales as is', () => { + setLocale('de') + expect(getCanonicalLocale()).toEqual('de') + setLocale('zu') + expect(getCanonicalLocale()).toEqual('zu') + }) - it('Returns extended locales with hyphens', () => { - setLocale('az_Cyrl_AZ') - expect(getCanonicalLocale()).toEqual('az-Cyrl-AZ') - setLocale('de_DE') - expect(getCanonicalLocale()).toEqual('de-DE') - }) + it('Returns extended locales with hyphens', () => { + setLocale('az_Cyrl_AZ') + expect(getCanonicalLocale()).toEqual('az-Cyrl-AZ') + setLocale('de_DE') + expect(getCanonicalLocale()).toEqual('de-DE') + }) })