diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2022-10-19 13:45:57 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2022-10-21 10:10:57 +0200 |
| commit | 0c6bf34d23c44f5c7c16a03f089291eb3c1876cb (patch) | |
| tree | c841385cb1fc948c7c65133aa6f53bdba4fc05df | |
| parent | 2ecd4228773aa06ee448e1285ffb48b9d3df2ba8 (diff) | |
| download | mullvadvpn-0c6bf34d23c44f5c7c16a03f089291eb3c1876cb.tar.xz mullvadvpn-0c6bf34d23c44f5c7c16a03f089291eb3c1876cb.zip | |
Add check for html format to translations check
| -rw-r--r-- | .github/workflows/translations.yml | 2 | ||||
| -rw-r--r-- | gui/scripts/verify-format-specifiers.ts | 73 | ||||
| -rw-r--r-- | gui/scripts/verify-translations-format.ts | 130 |
3 files changed, 131 insertions, 74 deletions
diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index c6bb477ab1..dde8b9c2fd 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -56,4 +56,4 @@ jobs: - name: Check if format specifiers are correct working-directory: gui/scripts - run: npm exec ts-node verify-format-specifiers.ts + run: npm exec ts-node verify-translations-format.ts diff --git a/gui/scripts/verify-format-specifiers.ts b/gui/scripts/verify-format-specifiers.ts deleted file mode 100644 index 2d161548d5..0000000000 --- a/gui/scripts/verify-format-specifiers.ts +++ /dev/null @@ -1,73 +0,0 @@ -import fs from 'fs'; -import { GetTextTranslation, po } from 'gettext-parser'; -import path from 'path'; - -const LOCALES_DIR = path.join('..', 'locales'); - -function getLocales(): string[] { - const localesContent = fs.readdirSync(LOCALES_DIR); - const localeDirectories = localesContent.filter((item) => - fs.statSync(path.join(LOCALES_DIR, item)).isDirectory(), - ); - return localeDirectories; -} - -function parseTranslationsForLocale(locale: string): GetTextTranslation[] { - const poFileContents = fs.readFileSync(path.join(LOCALES_DIR, locale, 'messages.po')); - const contexts = po.parse(poFileContents).translations; - - const translations = Object.values(contexts) - .flatMap((context) => Object.values(context)) - .filter((translation) => translation.msgid !== ''); - - return translations; -} - -function getFormatSpecifiers(text: string): string[] { - // Matches both %(name)s and %s. - return text.match(/%(\(.*?\))?[a-z]/g) ?? []; -} - -function formatSpecifiersEquals(source: string[], translation: string[]): boolean { - const sortedTranslation = translation.sort(); - return ( - source.length === translation.length && - source.sort().every((value, index) => value === sortedTranslation[index]) - ); -} - -function checkTranslationImpl(msgid: string, msgstr: string): boolean { - const sourceFormatSpecifiers = getFormatSpecifiers(msgid); - const translationFormatSpecifiers = getFormatSpecifiers(msgstr); - return formatSpecifiersEquals(sourceFormatSpecifiers, translationFormatSpecifiers); -} - -function checkTranslation(translation: GetTextTranslation): boolean { - return translation.msgstr - .map((msgstr) => { - // Make sure that the translation matches either the singular or plural. - const equal = - checkTranslationImpl(translation.msgid, msgstr) || - (translation.msgid_plural && checkTranslationImpl(translation.msgid_plural, msgstr)); - - if (!equal) { - console.error(`Error in "${translation.msgid}", "${msgstr}"`); - } - - return equal; - }) - .every((result) => result); -} - -const isCorrect = getLocales() - .map(parseTranslationsForLocale) - // Map first to output all errors - .map((translations) => translations.every(checkTranslation)) - .every((result) => result); - -if (isCorrect) { - console.log('Looks good!'); -} else { - console.error('See above errors'); - process.exit(1); -} diff --git a/gui/scripts/verify-translations-format.ts b/gui/scripts/verify-translations-format.ts new file mode 100644 index 0000000000..2ed927feaa --- /dev/null +++ b/gui/scripts/verify-translations-format.ts @@ -0,0 +1,130 @@ +import fs from 'fs'; +import { GetTextTranslation, po } from 'gettext-parser'; +import path from 'path'; + +const LOCALES_DIR = path.join('..', 'locales'); + +const ALLOWED_TAGS = ['b']; + +function getLocales(): string[] { + const localesContent = fs.readdirSync(LOCALES_DIR); + const localeDirectories = localesContent.filter((item) => + fs.statSync(path.join(LOCALES_DIR, item)).isDirectory(), + ); + return localeDirectories; +} + +function parseTranslationsForLocale(locale: string): GetTextTranslation[] { + const poFileContents = fs.readFileSync(path.join(LOCALES_DIR, locale, 'messages.po')); + const contexts = po.parse(poFileContents).translations; + + const translations = Object.values(contexts) + .flatMap((context) => Object.values(context)) + .filter((translation) => translation.msgid !== ''); + + return translations; +} + +function getFormatSpecifiers(text: string): string[] { + // Matches both %(name)s and %s. + return text.match(/%(\(.*?\))?[a-z]/g) ?? []; +} + +function formatSpecifiersEquals(source: string[], translation: string[]): boolean { + const sortedTranslation = translation.sort(); + return ( + source.length === translation.length && + source.sort().every((value, index) => value === sortedTranslation[index]) + ); +} + +function checkFormatSpecifiersImpl(msgid: string, msgstr: string): boolean { + const sourceFormatSpecifiers = getFormatSpecifiers(msgid); + const translationFormatSpecifiers = getFormatSpecifiers(msgstr); + return formatSpecifiersEquals(sourceFormatSpecifiers, translationFormatSpecifiers); +} + +function checkFormatSpecifiers(translation: GetTextTranslation): boolean { + return translation.msgstr + .map((msgstr) => { + // Make sure that the translation matches either the singular or plural. + const equal = + checkFormatSpecifiersImpl(translation.msgid, msgstr) || + (translation.msgid_plural && checkFormatSpecifiersImpl(translation.msgid_plural, msgstr)); + + if (!equal) { + console.error(`Error in "${translation.msgid}", "${msgstr}"`); + } + + return equal; + }) + .every((result) => result); +} + +function checkHtmlTagsImpl(value: string): { correct: boolean, amount: number } { + const tagsRegexp = new RegExp("<.*?>", "g"); + const tags = value.match(tagsRegexp) ?? []; + const tagTypes = tags.map((tag) => tag.slice(1, -1)); + + // Make sure tags match by pushing start-tags to a stack and matching closing tags with the last + // item. + let tagStack: string[] = []; + for (let tag of tagTypes) { + const endTag = tag.startsWith('/'); + tag = endTag ? tag.slice(1) : tag; + + if (!ALLOWED_TAGS.includes(tag)) { + console.error(`Tag "<${tag}>" not allowed: "${value}"`); + return { correct: false, amount: NaN }; + } + + if (endTag) { + // End tags require a matching start tag. + if (tag !== tagStack.pop()) { + console.error(`Closing non-existent start-tag (</${tag}>) in "${value}"`); + return { correct: false, amount: NaN }; + } + } else { + tagStack.push(tag); + } + } + + if (tagStack.length > 0) { + console.error(`Missing closing-tags (${tagStack}) in "${value}"`); + return { correct: false, amount: NaN }; + } + + return { correct: true, amount: tags.length / 2 }; +} + +function checkHtmlTags(translation: GetTextTranslation): boolean { + let { correct, amount: sourceAmount } = checkHtmlTagsImpl(translation.msgid); + + let translationsCorrect = translation.msgstr.every((value) => { + let { correct, amount } = checkHtmlTagsImpl(value); + // The amount doesn't make sense if the string isn't correctly formatted. + if (correct && amount !== sourceAmount) { + console.error(`Incorrect amount of tags in translation for "${translation.msgid}": "${value}"`); + } + return correct && amount === sourceAmount; + }); + + return correct && translationsCorrect; +} + +function checkTranslation(translation: GetTextTranslation): boolean { + return checkFormatSpecifiers(translation) && checkHtmlTags(translation); +} + +const isCorrect = getLocales() + .map(parseTranslationsForLocale) + // Map first to output all errors + .map((translations) => translations.every(checkTranslation)) + .every((result) => result); + +if (isCorrect) { + console.log('Looks good!'); +} else { + console.error('See above errors'); + process.exit(1); +} |
