Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test_build_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ jobs:
upload_sources: true
upload_translations: false
download_translations: true
export_only_approved: true
skip_untranslated_files: true
push_translations: false
create_pull_request: false
Expand All @@ -94,12 +95,17 @@ jobs:
upload_sources: false
upload_translations: false
download_translations: true
export_only_approved: true
skip_untranslated_files: false
push_translations: false
create_pull_request: false
token: ${{secrets.WEB_CROWDIN_TOKEN}}
config: ${{env.CROWDIN_CONFIG_PATH}}

- name: Validate downloaded translations
if: (github.event_name == 'pull_request' && (github.actor != 'dependabot[bot]' && github.actor != 'otto-the-bot')) || env.BRANCH_NAME == 'master'
run: yarn translate:validate

- name: Build
if: env.BRANCH_NAME == 'master'
# We need to execute `yarn translate:process` with sudo in order to delete and modify files
Expand Down
96 changes: 96 additions & 0 deletions bin/validate-translations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
const fileSystem = require('node:fs');
const path = require('node:path');

const numericHtmlTagPattern = /<\/?[0-9]+(?=\s|\/?>)[^>]*>/g;

function findInvalidPlaceholderTags(htmlContent) {
return htmlContent.match(numericHtmlTagPattern) || [];
}

function collectHtmlFiles(directoryPath) {
const directoryEntries = fileSystem
.readdirSync(directoryPath, {withFileTypes: true})
.sort((firstEntry, secondEntry) => firstEntry.name.localeCompare(secondEntry.name));
const htmlFilePaths = [];

for (const directoryEntry of directoryEntries) {
const entryPath = path.join(directoryPath, directoryEntry.name);

if (directoryEntry.isDirectory()) {
htmlFilePaths.push(...collectHtmlFiles(entryPath));
continue;
}

if (directoryEntry.isFile() && path.extname(directoryEntry.name).toLowerCase() === '.html') {
htmlFilePaths.push(entryPath);
}
}

return htmlFilePaths;
}

function findInvalidTranslations(translationDirectories) {
const invalidTranslations = [];

for (const translationDirectory of translationDirectories) {
const htmlFilePaths = collectHtmlFiles(translationDirectory);

for (const htmlFilePath of htmlFilePaths) {
const htmlContent = fileSystem.readFileSync(htmlFilePath, 'utf8');
const invalidPlaceholderTags = findInvalidPlaceholderTags(htmlContent);

if (invalidPlaceholderTags.length > 0) {
invalidTranslations.push({
filePath: htmlFilePath,
invalidPlaceholderTags,
});
}
}
}

return invalidTranslations;
}

function formatValidationErrors(invalidTranslations, rootDirectory) {
return invalidTranslations
.flatMap(invalidTranslation =>
invalidTranslation.invalidPlaceholderTags.map(
invalidPlaceholderTag =>
`${path.relative(rootDirectory, invalidTranslation.filePath)}: invalid numeric Crowdin placeholder ${invalidPlaceholderTag}`,
),
)
.join('\n');
}

function main() {
const rootDirectory = path.resolve(__dirname, '..');
const translationDirectories = ['src/pages', 'src/partials'].map(translationDirectory =>
path.join(rootDirectory, translationDirectory),
);

try {
const invalidTranslations = findInvalidTranslations(translationDirectories);

if (invalidTranslations.length > 0) {
console.error('Invalid numeric Crowdin placeholder tags found:');
console.error(formatValidationErrors(invalidTranslations, rootDirectory));
process.exitCode = 1;
return;
}

console.log('Translation HTML validation passed.');
} catch (error) {
console.error(`Unable to validate translations: ${error.message}`);
process.exitCode = 1;
}
}

if (require.main === module) {
main();
}

module.exports = {
findInvalidPlaceholderTags,
findInvalidTranslations,
formatValidationErrors,
};
72 changes: 72 additions & 0 deletions bin/validate-translations.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const assert = require('node:assert');
const fileSystem = require('node:fs');
const operatingSystem = require('node:os');
const path = require('node:path');
const {test} = require('node:test');

const {
findInvalidPlaceholderTags,
findInvalidTranslations,
formatValidationErrors,
} = require('./validate-translations');

function createTemporaryTranslationDirectory() {
return fileSystem.mkdtempSync(path.join(operatingSystem.tmpdir(), 'wire-emails-translations-'));
}

function removeTemporaryTranslationDirectory(directoryPath) {
fileSystem.rmSync(directoryPath, {force: true, recursive: true});
}

test('accepts ordinary HTML', () => {
const actualInvalidTags = findInvalidPlaceholderTags('<p>Hello <strong>world</strong>.</p>');

assert.deepStrictEqual(actualInvalidTags, []);
});

test('accepts project variables', () => {
const actualInvalidTags = findInvalidPlaceholderTags('<p>Welcome to ${brand}, ${email}.</p>');

assert.deepStrictEqual(actualInvalidTags, []);
});

test('detects an opening numeric placeholder tag', () => {
const actualInvalidTags = findInvalidPlaceholderTags('<p><0>Translated text</0></p>');

assert.strictEqual(actualInvalidTags[0], '<0>');
});

test('detects a closing numeric placeholder tag', () => {
const actualInvalidTags = findInvalidPlaceholderTags('<p>Translated text</1>');

assert.deepStrictEqual(actualInvalidTags, ['</1>']);
});

test('reports filenames and multiple invalid placeholders recursively', () => {
const temporaryDirectory = createTemporaryTranslationDirectory();
const pagesDirectory = path.join(temporaryDirectory, 'src', 'pages');
const partialsDirectory = path.join(temporaryDirectory, 'src', 'partials');
const invalidPagePath = path.join(pagesDirectory, 'ru', 'email.html');
const invalidPartialPath = path.join(partialsDirectory, 'fr', 'footer.html');

try {
fileSystem.mkdirSync(path.dirname(invalidPagePath), {recursive: true});
fileSystem.mkdirSync(path.dirname(invalidPartialPath), {recursive: true});
fileSystem.writeFileSync(invalidPagePath, '<p><0>Russian text</1></p>');
fileSystem.writeFileSync(invalidPartialPath, '<p>French text <2></p>');

const actualInvalidTranslations = findInvalidTranslations([pagesDirectory, partialsDirectory]);
const actualErrorOutput = formatValidationErrors(actualInvalidTranslations, temporaryDirectory);

assert.strictEqual(actualInvalidTranslations.length, 2);
assert.strictEqual(actualInvalidTranslations[0].invalidPlaceholderTags.length, 2);
assert.strictEqual(actualInvalidTranslations[1].invalidPlaceholderTags.length, 1);
assert.strictEqual(actualErrorOutput.includes(path.join('src', 'pages', 'ru', 'email.html')), true);
assert.strictEqual(actualErrorOutput.includes(path.join('src', 'partials', 'fr', 'footer.html')), true);
assert.strictEqual(actualErrorOutput.includes('<0>'), true);
assert.strictEqual(actualErrorOutput.includes('</1>'), true);
assert.strictEqual(actualErrorOutput.includes('<2>'), true);
} finally {
removeTemporaryTranslationDirectory(temporaryDirectory);
}
});
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,18 @@
"name": "wire-emails",
"private": true,
"scripts": {
"build": "yarn translate:process && gulp build --production && yarn text",
"build": "yarn translate:validate && yarn translate:process && gulp build --production && yarn text",
"text": "node bin/generate-text-emails.js",
"deploy": "npm version patch",
"postversion": "git push && git push --tags",
"prettier": "prettier --ignore-path .gitignore --write \"**/*.{js,scss,md,yml,yaml}\"",
"start": "gulp",
"test": "yarn build",
"test": "yarn test:unit && yarn build",
"translate": "yarn translate:upload && yarn translate:download",
"translate:download": "crowdin download && yarn translate:process",
"translate:download": "crowdin download && yarn translate:validate && yarn translate:process",
"translate:process": "node bin/translate.js",
"translate:validate": "node bin/validate-translations.js",
"test:unit": "node --test bin/validate-translations.test.js",
"translate:upload": "crowdin upload sources",
"prepare": "husky install"
},
Expand Down
2 changes: 1 addition & 1 deletion src/partials/fr/footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
<span>&middot;</span>
<a href="mailto:${misuse}">Signaler un abus</a>
<br>
${copyright}. TOUS DROITS RÉSERVÉS.</0>
${copyright}. TOUS DROITS RÉSERVÉS.
</p>
</container>
Loading