diff --git a/examples/example.xml b/examples/example.xml index e70ef4e..c59e91e 100644 --- a/examples/example.xml +++ b/examples/example.xml @@ -29,10 +29,6 @@ text/plain; charset=UTF-8 8bit nplurals=2; plural=(n != 1); - - Custom Header - Custom Header 2 - @@ -40,7 +36,7 @@ This is a comment for the translator as a string. - + as a key reference diff --git a/src/Gettext/Generator/XMLGenerator.ts b/src/Gettext/Generator/XMLGenerator.ts index 9f83283..a98ebe6 100644 --- a/src/Gettext/Generator/XMLGenerator.ts +++ b/src/Gettext/Generator/XMLGenerator.ts @@ -34,11 +34,38 @@ export default class XMLGenerator implements GettextGeneratorInterface { --> -Translation for language : ${translations.language} -Translation for language : ${translations.language} \n`); const spaces = ' '.repeat(4); + let comments = translations.attributes.comments.all; + let title : string = `Translation for language : ${translations.language}`; + let description : string = `The translation for the language : ${translations.language}`; + for (let i = 0; i < comments.length; i++) { + if (comments[i].startsWith('')) { + let match = comments[i].match(/^<title>(.*)<\/title>/); + if (match) { + title = match[1].trim() || title; + } else { + //strip the title tag + title = comments[i].replace(/<[^>]+>/g, '').trim() || title; + } + comments.splice(i, 1); + continue; + } + if (comments[i].startsWith('<description>')) { + let match = comments[i].match(/^<description>(.*)<\/description>/); + if (match) { + description = match[1].trim() || description; + } else { + //strip the description tag + description = comments[i].replace(/<[^>]+>/g, '').trim() || description; + } + comments.splice(i, 1); + } + } + + stream.write(`${spaces}<title>${encode_entities(title)}\n`); + stream.write(`${spaces}${encode_entities(description)}\n`); if (translations.attributes.flags.length > 0) { let content: string[] = [`${spaces}`]; translations.attributes.flags.forEach((flag: string) => { @@ -63,7 +90,7 @@ export default class XMLGenerator implements GettextGeneratorInterface { content.push(`${spaces}`); stream.write(content.join('\n') + '\n'); } - if (translations.attributes.comments.length > 0) { + if (comments.length > 0) { let content: string[] = [`${spaces}`]; translations.attributes.comments.forEach((comment: string) => { comment = encode_entities(comment); diff --git a/src/Gettext/Metadata/Headers.ts b/src/Gettext/Metadata/Headers.ts index 9ea1e01..93ef424 100644 --- a/src/Gettext/Metadata/Headers.ts +++ b/src/Gettext/Metadata/Headers.ts @@ -89,9 +89,9 @@ export default class Headers implements GettextHeadersInterface { * @inheritDoc */ public setFallbackLanguage(language: string): void { - let locale = normalizeLocale(language); - if (locale) { - this.#fallbackLanguage = language; + let info = getLocaleInfo(language); + if (info) { + this.#fallbackLanguage = info.id; } } @@ -326,12 +326,14 @@ export default class Headers implements GettextHeadersInterface { this.#headers[normalizedName] = this.pluralForm.header; break; case HEADER_LANGUAGE_KEY: - let locale = normalizeLocale(value); - if (locale) { - let info = getLocaleInfo(locale); - this.#headers[normalizedName] = info ? info.id : locale; - if (info) { - this.#headers[HEADER_LANGUAGE_NAME_KEY] = info.name; + let localeInfo = getLocaleInfo(value); + if (localeInfo) { + this.#headers[normalizedName] = localeInfo.id; + this.#headers[HEADER_LANGUAGE_NAME_KEY] = localeInfo.name; + } else { + let language = normalizeLocale(value); + if (language) { + this.#headers[normalizedName] = language; } } break; diff --git a/src/Gettext/Reader/XMLReader.ts b/src/Gettext/Reader/XMLReader.ts index 76baea6..c4412fb 100644 --- a/src/Gettext/Reader/XMLReader.ts +++ b/src/Gettext/Reader/XMLReader.ts @@ -55,10 +55,6 @@ import { * text/plain; charset=UTF-8 * 8bit * nplurals=2; plural=(n != 1); - * - * Custom Header - * Custom Header 2 - * * * * @@ -66,7 +62,7 @@ import { * * This is a comment for the translator as a string. * - * + * * as a key reference * * @@ -175,7 +171,14 @@ export default class XMLReader implements GettextReaderInterface{ ); } const translations = new GettextTranslations(parseInt(revision + '')); - + let title = rootFilter('title', rootElement.children).map((node) => node.textContent)[0]; + if (is_string(title)) { + translations.attributes.comments.add('' + this.cleanData(title) + ''); + } + let description = rootFilter('description', rootElement.children).map((node) => node.textContent)[0]; + if (is_string(description)) { + translations.attributes.comments.add('' + this.cleanData(description) + ''); + } rootFilter('headers', rootElement.children).forEach((headerElement) : void => { for (let child in headerElement.children) { const node = headerElement.children[child]; @@ -265,6 +268,14 @@ export default class XMLReader implements GettextReaderInterface{ `The translation is not valid, ${(e as Error).message}` ); } + + let disabled = rootFilter('enable', itemElement?.children).map((node) => node.textContent)[0]; + if (is_string(disabled)) { + disabled = disabled.trim().toLowerCase(); + if (disabled === 'false' || disabled === '0') { + gettextTranslation.setEnabled(false); + } + } // append translation translations.add(gettextTranslation); // > + diff --git a/src/Schema/translation.xsd b/src/Schema/translation.xsd index e54242c..42dcea6 100644 --- a/src/Schema/translation.xsd +++ b/src/Schema/translation.xsd @@ -1,5 +1,5 @@ - + The Gettext XML Schema is used to validate the structure of a Gettext translation file. @@ -9,7 +9,7 @@ - + The flags used to mark the translation file. The flag is case-insensitive. @@ -25,13 +25,13 @@ - + The references used to identify the original source of the translations. - + @@ -41,13 +41,15 @@ - + The comments used to provide additional information about the translation file. + + @@ -55,10 +57,22 @@ + + + + The title of the translation file. + + + + + The domain of the translation file. + + + @@ -68,6 +82,7 @@ + @@ -92,7 +107,7 @@ The last-translator key is the name and email address of the last translator. - + The language key is the language code of the translation. @@ -117,7 +132,7 @@ The mime-version key is the MIME version of the translation file. - + The content-type key is the content type of the translation file. @@ -127,7 +142,7 @@ - + The content-transfer-encoding key is the content transfer encoding of the translation file. @@ -141,7 +156,7 @@ - + The plural-forms key is the plural forms of the translation file. @@ -151,7 +166,7 @@ - + @@ -178,7 +193,7 @@ - + @@ -194,10 +209,11 @@ - - - - + + + The enable key is used to enable or disable the translation. + + @@ -207,7 +223,7 @@ - + @@ -223,7 +239,7 @@ - + The revision number of the translation file. diff --git a/src/Translations/Translator.ts b/src/Translations/Translator.ts index 6cea245..031e807 100644 --- a/src/Translations/Translator.ts +++ b/src/Translations/Translator.ts @@ -3,7 +3,6 @@ import TranslationEntriesInterface from './Interfaces/TranslationEntriesInterfac import { DEFAULT_DOMAIN, DEFAULT_LANGUAGE, - getLocaleInfo, normalizeLocale } from '../Utils/Locale'; import { @@ -16,26 +15,6 @@ import TranslationEntryInterface from './Interfaces/TranslationEntryInterface'; import TranslationEntries from './TranslationEntries'; import GettextTranslationInterface from '../Gettext/Interfaces/GettextTranslationInterface'; -/** - * Filter the language - * - * @param language - */ -export const filter_language = (language: string): string | null => { - if (!is_string(language)) { - return null; - } - let info = getLocaleInfo(language); - if (info) { - return info.id; - } - let locale = normalizeLocale(language); - if (locale) { - return locale; - } - return null; -} - /** * The translator */ @@ -82,7 +61,7 @@ export default class Translator< * @inheritDoc */ public setOriginalLanguage(language: string): string | undefined { - let locale = filter_language(language); + let locale = normalizeLocale(language); if (!locale) { return undefined; } @@ -115,7 +94,7 @@ export default class Translator< * @inheritDoc */ public setLanguage(language: string) : string|undefined { - let locale = filter_language(language); + let locale = normalizeLocale(language); if (!locale) { return undefined; } @@ -163,7 +142,7 @@ export default class Translator< if (!language) { language = translations.headers.language; } - let locale = filter_language(language); + let locale = normalizeLocale(language); if (!locale) { // empty skipped return false; } diff --git a/src/Utils/Locale.ts b/src/Utils/Locale.ts index 1d8f4ec..eaa64e8 100644 --- a/src/Utils/Locale.ts +++ b/src/Utils/Locale.ts @@ -23,13 +23,16 @@ let setup = false; export const DEFAULT_DOMAIN = 'default'; export const DEFAULT_LANGUAGE = 'en'; +// @link {https://tools.ietf.org/html/rfc5646} +export const RFC5646_REGEX = /^(?:(?:en-GB-oed|i-(?:ami|bnn|default|enochian|hak|klingon|lux|mingo|navajo|pwn|tao|tay|tsu)|sgn-(?:BE-FR|BE-NL|CH-DE))|(?:art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang)|(?:(?:(?:[a-zA-Z]{2,3}(-([A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[a-zA-Z]{5,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|\d{3}))?(?:-(?:[A-Za-z0-9]{5,8}|\d[A-Z-a-z0-9]{3}))*(?:-(?:\d|[A-W]|[Y-Z]|[a-w]|[y-z])(-[A-Za-z0-9]{2,8})+)*)?)$/; + /** * Normalize the locale * * @param {string} locale the locale language * @return {string|null} string if it was exists in locale json */ -export const normalizeLocale = (locale: string): string | null => { +export const normalizeLocaleName = (locale: string): string | null => { if (!is_string(locale)) { return null; } @@ -46,12 +49,15 @@ export const normalizeLocale = (locale: string): string | null => { setup = true; Object.entries((Locales as LocaleItems)) .forEach(([key, value]) => { - LocaleIdentifierById[value.id] = key; + LocaleIdentifierById[value.id.toLowerCase()] = key; }); } - + locale = locale.replace(/[-_]+/g, '_').toLowerCase(); if (Locales.hasOwnProperty(locale)) { - return (Locales as LocaleItems)[locale] ? locale : null; + return locale; + } + if (LocaleIdentifierById.hasOwnProperty(locale)) { + return LocaleIdentifierById[locale]; } let match = locale.match(/^([a-z]{2})(?:[-_]([a-z]{2}))?(?:([a-z]{2})(?:[-_]([a-z]{2}))?)?(?:\..*)?$/i); @@ -63,7 +69,7 @@ export const normalizeLocale = (locale: string): string | null => { if (match[4]) { currentLocale = (match[1] + '_' + match[2] + match[3] + '_' + match[4]).toLowerCase(); if (LocaleIdentifierById.hasOwnProperty(currentLocale)) { - return (Locales as LocaleItems)[LocaleIdentifierById[currentLocale]] ? LocaleIdentifierById[currentLocale] : null; + return LocaleIdentifierById[currentLocale]; } } if (match[3]) { @@ -79,14 +85,41 @@ export const normalizeLocale = (locale: string): string | null => { } } currentLocale = match[1].toLowerCase(); - return (Locales as LocaleItems)[currentLocale] ? currentLocale : null; + return (Locales as LocaleItems)[currentLocale] ? currentLocale : ( + LocaleIdentifierById[currentLocale] || null + ); +} + +/** + * Normalize the locale + * @param {string} locale + * @return {string|null} + */ +export const normalizeLocale = (locale: string): string|null => { + if (!is_string(locale) || locale.trim() === '') { + return null; + } + let localeInfo = getLocaleInfo(locale); + if (localeInfo) { + return localeInfo.id; + } + locale = locale.trim().replace(/[-_]+/g, '-').toLowerCase().replace(/(^-|-$)/g, ''); + let match = locale.match(RFC5646_REGEX); + if (!match) { + return null; + } + let locales = locale.split('-'); + let first : string = locales.shift() as string; + locales = locales.map((locale) => locale.toUpperCase()); + locales.unshift(first); + return locales.join('_'); } /** * Get the locale information */ export const getLocaleInfo = (locale?: string): LocaleItem | null => { - let normalizedLocale = normalizeLocale(locale || ''); + let normalizedLocale = normalizeLocaleName(locale || ''); if (!normalizedLocale) { return null; } diff --git a/src/__tests__/Utils/Locale.test.ts b/src/__tests__/Utils/Locale.test.ts index 753e878..1858655 100644 --- a/src/__tests__/Utils/Locale.test.ts +++ b/src/__tests__/Utils/Locale.test.ts @@ -1,7 +1,8 @@ import { getLocaleInfo, normalizeLocale, - LocaleItem + LocaleItem, + normalizeLocaleName } from '../../Utils/Locale'; import Locales from '../../Utils/locales.json' @@ -29,7 +30,7 @@ describe('locales.json', () => { }); }); -describe('normalizeLocale', () => { +describe('normalizeLocaleName', () => { /** * Test to check if normalizeLocale returns null for non-string input. */ @@ -40,28 +41,59 @@ describe('normalizeLocale', () => { }); /** - * Test to check if normalizeLocale returns null for invalid locale strings. + * Test to check if normalizeLocaleName returns null for non-string input. + */ + test('should return null for non-string input', () => { + expect(normalizeLocaleName(123 as any)).toBeNull(); + expect(normalizeLocaleName(null as any)).toBeNull(); + expect(normalizeLocaleName(undefined as any)).toBeNull(); + }); + + /** + * Test to check normalizeLocale returns null for invalid locale strings. + */ + test('should return null for invalid locale strings', () => { + /** + * @see https://tools.ietf.org/html/rfc5646 + */ + expect(normalizeLocale(' ')).toBeNull(); // whitespace should be trimmed and not allowed + expect(normalizeLocale('.invalid')).toBeNull(); // invalid characters + expect(normalizeLocale('a')).toBeNull(); // too short + expect(normalizeLocale('thisisaverylonglocalename')).toBeNull() // invalid length + expect(normalizeLocale('en')).toEqual('en'); + expect(normalizeLocale('en_')).toEqual('en'); + expect(normalizeLocale('en-US')).toEqual('en_US'); + expect(normalizeLocale('de-CH-1996')).toEqual('de_CH_1996'); + expect(normalizeLocale('de-AB-ab')).toBeNull(); // invalid variant + expect(normalizeLocale('de-ABC-ab')).toEqual('de_ABC_AB'); + expect(normalizeLocale('de-AB-abc')).toBeNull(); // invalid variant + expect(normalizeLocale('de-ABC-abc')).toEqual('de_ABC_ABC'); + expect(normalizeLocale('de-AB-ab1')).toBeNull(); // invalid variant + }); + + /** + * Test to check if normalizeLocaleName returns null for invalid locale strings. */ test('should return null for invalid locale strings', () => { - expect(normalizeLocale('')).toBeNull(); - expect(normalizeLocale('.invalid')).toBeNull(); - expect(normalizeLocale('a')).toBeNull(); - expect(normalizeLocale('thisisaverylonglocalename')).toBeNull(); + expect(normalizeLocaleName('')).toBeNull(); + expect(normalizeLocaleName('.invalid')).toBeNull(); + expect(normalizeLocaleName('a')).toBeNull(); + expect(normalizeLocaleName('thisisaverylonglocalename')).toBeNull(); }); /** - * Test to check if normalizeLocale returns the correct normalized locale for valid locale strings. + * Test to check if normalizeLocaleName returns the correct normalized locale for valid locale strings. */ test('should return the correct normalized locale for valid locale strings', () => { const localeKey = Object.keys(Locales)[0]; - expect(normalizeLocale(localeKey)).toEqual(localeKey); + expect(normalizeLocaleName(localeKey)).toEqual(localeKey); }); /** - * Test to check if normalizeLocale returns null for non-existent locale strings. + * Test to check if normalizeLocaleName returns null for non-existent locale strings. */ test('should return null for non-existent locale strings', () => { - expect(normalizeLocale('nonexistent')).toBeNull(); + expect(normalizeLocaleName('nonexistent')).toBeNull(); }); });