huridocs/uwazi

View on GitHub
scripts/checkTranslations.mjs

Summary

Maintainability
Test Coverage
import parser from '@babel/parser';
import traverse from '@babel/traverse';
import mongodb from 'mongodb';

import { resolve } from 'path';
// eslint-disable-next-line node/no-restricted-import
import { promises } from 'fs';

async function getFiles(dir) {
  const dirents = await promises.readdir(dir, { withFileTypes: true });
  const files = await Promise.all(
    dirents.map(dirent => {
      const res = resolve(dir, dirent.name);
      return dirent.isDirectory() ? getFiles(res) : res;
    })
  );
  return Array.prototype
    .concat(...files)
    .filter(
      file =>
        !file.match('.spec') &&
        !file.match('.stories') &&
        !file.match('.cy') &&
        (file.endsWith('.js') ||
          file.endsWith('.ts') ||
          file.endsWith('.tsx') ||
          file.endsWith('.jsx'))
    );
}

const parserOptions = {
  sourceType: 'module',

  plugins: [
    // enable jsx and typescript syntax
    'jsx',
    'typescript',
  ],
};

const processTextNode = (path, file) => {
  const wordRegexp = /\b[a-zA-Z]+\b/g;
  const text = path.node.value.trim();
  const parentTag = path.parent.openingElement;
  const container = parentTag?.name.name;
  if (!wordRegexp.test(text)) {
    return null;
  }

  let key;
  if (container === 'Translate' && container && parentTag.attributes.length) {
    key = parentTag.attributes.find(a => a.name.name === 'translationKey')?.value.value;
  }
  const shortName = file.split('app/react/').pop();
  return { text, container, file: shortName, key };
};

const processTFunction = (path, file) => {
  const shortName = file.split('app/react/').pop();
  const key = path.node.arguments[1].value;
  const text = path.node.arguments[2]?.value ? path.node.arguments[2].value : key;

  if (!text) {
    return null;
  }

  return { text: text || key, container: 't', file: shortName, key };
};

const comparableString = text => text.replaceAll(/['\s;]|&(#39|#x27|quot|rsquo|apos);/g, '');

async function parseFile(file, translations) {
  const result = [];
  const fileContents = await promises.readFile(file, 'utf8');

  if (!file.includes('/migrations/')) {
    const comparableContent = comparableString(fileContents);

    translations
      .filter(translation => !translation.used)
      .forEach(translation => {
        if (
          comparableContent.includes(translation.plainValue) ||
          comparableContent.includes(translation.plainKey)
        ) {
          // eslint-disable-next-line no-param-reassign
          translation.used = true;
        }
      });
  }

  if (file.includes('app/react')) {
    const ast = parser.parse(fileContents, parserOptions);
    traverse.default(ast, {
      enter(path) {
        if (
          path.isCallExpression() &&
          path.node.callee.name === 't' &&
          path.node.arguments[0].value === 'System'
        ) {
          result.push(processTFunction(path, file));
        }
        if (path.isJSXElement()) {
          const noTranslate = path.node.openingElement.attributes.find(
            a => a.name?.name === 'no-translate'
          );
          if (noTranslate) {
            path.skip();
          }
        }
        if (path.isJSXText()) {
          result.push(processTextNode(path, file));
        }
      },
    });
    return result.filter(t => t);
  }
  return [];
}

const getClient = async () => {
  const url = process.env.DBHOST ? `mongodb://${process.env.DBHOST}/` : 'mongodb://127.0.0.1/';
  const client = new mongodb.MongoClient(url, { useUnifiedTopology: true });
  await client.connect();

  return client;
};

const getSystemUITranslations = async () => {
  const client = await getClient();
  const db = client.db(process.env.DATABASE_NAME || 'uwazi_development');
  const collection = db.collection('translationsV2');
  const translations = await collection.find({ 'context.id': 'System' }).toArray();
  client.close();
  const comparableTranslations = translations.map(t => ({
    ...t,
    plainValue: comparableString(t.value),
    plainKey: comparableString(t.key),
  }));
  return { translations, comparableTranslations };
};

const checkSystemKeys = async (allTexts, translations) => {
  const textsNotInTranslations = allTexts.filter(text => {
    let key = text.key || text.text;
    key = key.trim().replace(/\n\s*/g, ' ');
    return !translations.find(t => t.key === key);
  });

  return textsNotInTranslations;
};

const reportNoTranslateElement = textsWithoutTranslateElement => {
  if (!textsWithoutTranslateElement.length) {
    return;
  }

  textsWithoutTranslateElement.forEach(({ text, container, file }) => {
    process.stdout.write(`\x1b[36m ${file}\x1b[37m ${text}\x1b[31m ${container}\x1b[0m \n`);
  });

  process.stdout.write(
    ` === Found \x1b[31m ${textsWithoutTranslateElement.length} \x1b[0m texts not wrapped in a <Translate> element === \n`
  );
};

const reportnotInTranslations = textsNotInTranslations => {
  if (!textsNotInTranslations.length) {
    return;
  }
  textsNotInTranslations.forEach(({ text, file }) => {
    process.stdout.write(` \x1b[36m ${file} \x1b[37m ${text}\x1b[0m \n`);
  });

  process.stdout.write(
    ` === Found \x1b[31m ${textsNotInTranslations.length} \x1b[0m texts not in translations collection ===\n`
  );
};

const checkForPotentialObsoleteTranslations = comparableTranslations => {
  const nonUsed = comparableTranslations.filter(translation => !translation.used);
  if (!nonUsed.length) {
    return [];
  }
  nonUsed.forEach(({ key, value }) => {
    process.stdout.write(` \x1b[36m ${key} \x1b[37m ${value}\x1b[0m \n`);
  });

  process.stdout.write(
    ` === Found \x1b[31m ${nonUsed.length} \x1b[0m potential obsolete translations ===\n`
  );

  return nonUsed;
};

const checkForMissingTranslations = async (translations, results) => {
  const allTexts = results.flat();
  const textsNotInTranslations = await checkSystemKeys(allTexts, translations);
  const textsWithoutTranslateElement = allTexts.filter(
    t => t.container !== 'Translate' && t.container !== 't'
  );
  reportNoTranslateElement(textsWithoutTranslateElement);
  reportnotInTranslations(textsNotInTranslations);
  return { textsNotInTranslations, textsWithoutTranslateElement };
};

async function checkTranslations(dir) {
  const files = await getFiles(dir);
  const { translations, comparableTranslations } = await getSystemUITranslations();
  const results = await Promise.all(files.map(file => parseFile(file, comparableTranslations)));
  const nonUsed = checkForPotentialObsoleteTranslations(comparableTranslations);
  const { textsNotInTranslations, textsWithoutTranslateElement } =
    await checkForMissingTranslations(translations, results);
  if (textsNotInTranslations.length || textsWithoutTranslateElement.length || nonUsed.length) {
    process.exit(1);
  } else {
    process.stdout.write('\x1b[32m All good! \x1b[0m\n');
    process.exit(0);
  }
}

checkTranslations('./app');