summaryrefslogtreecommitdiffhomepage
path: root/gui/scripts/verify-translations-format.ts
blob: 2ed927feaa15bf1e0a716c0267b5b4cd6cef61fb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
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);
}