From bbefc11b480d111391ee8ab8642adde6f297515f Mon Sep 17 00:00:00 2001
From: Christian Rackerseder
Date: Thu, 16 Jul 2026 18:34:01 +0200
Subject: [PATCH 1/2] Reject invalid Crowdin translation placeholders
Require approved Crowdin exports and validate numeric placeholder tags before translation processing and builds. Add validator tests and remove the existing stray 0> placeholder.
---
bin/validate-translations.js | 96 +++++++++++++++++++++++++++++++
bin/validate-translations.test.js | 72 +++++++++++++++++++++++
package.json | 8 ++-
src/partials/fr/footer.html | 2 +-
4 files changed, 174 insertions(+), 4 deletions(-)
create mode 100644 bin/validate-translations.js
create mode 100644 bin/validate-translations.test.js
diff --git a/bin/validate-translations.js b/bin/validate-translations.js
new file mode 100644
index 00000000..9c3cfeff
--- /dev/null
+++ b/bin/validate-translations.js
@@ -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,
+};
diff --git a/bin/validate-translations.test.js b/bin/validate-translations.test.js
new file mode 100644
index 00000000..39b8d495
--- /dev/null
+++ b/bin/validate-translations.test.js
@@ -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('Hello world.
');
+
+ assert.deepStrictEqual(actualInvalidTags, []);
+});
+
+test('accepts project variables', () => {
+ const actualInvalidTags = findInvalidPlaceholderTags('Welcome to ${brand}, ${email}.
');
+
+ assert.deepStrictEqual(actualInvalidTags, []);
+});
+
+test('detects an opening numeric placeholder tag', () => {
+ const actualInvalidTags = findInvalidPlaceholderTags('<0>Translated text0>
');
+
+ assert.strictEqual(actualInvalidTags[0], '<0>');
+});
+
+test('detects a closing numeric placeholder tag', () => {
+ const actualInvalidTags = findInvalidPlaceholderTags('Translated text1>');
+
+ 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, '
<0>Russian text1>
');
+ fileSystem.writeFileSync(invalidPartialPath, 'French text <2>
');
+
+ 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);
+ }
+});
diff --git a/package.json b/package.json
index d1bfbffa..6261d850 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/src/partials/fr/footer.html b/src/partials/fr/footer.html
index 41ccab77..b5144a4a 100644
--- a/src/partials/fr/footer.html
+++ b/src/partials/fr/footer.html
@@ -5,6 +5,6 @@
·
Signaler un abus
- ${copyright}. TOUS DROITS RÉSERVÉS.0>
+ ${copyright}. TOUS DROITS RÉSERVÉS.
From c37b6279701468b02d1abea4713e82c25698fb60 Mon Sep 17 00:00:00 2001
From: Christian Rackerseder
Date: Thu, 16 Jul 2026 18:34:41 +0200
Subject: [PATCH 2/2] Validate approved translations in deployment workflow
Require Crowdin exports to contain approved translations only and validate downloaded HTML before build, commit, and deployment.
---
.github/workflows/test_build_deploy.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/test_build_deploy.yml b/.github/workflows/test_build_deploy.yml
index f41930c0..d5cbac39 100644
--- a/.github/workflows/test_build_deploy.yml
+++ b/.github/workflows/test_build_deploy.yml
@@ -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
@@ -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