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 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 text

'); + + assert.strictEqual(actualInvalidTags[0], '<0>'); +}); + +test('detects a closing numeric placeholder tag', () => { + const actualInvalidTags = findInvalidPlaceholderTags('

Translated text'); + + assert.deepStrictEqual(actualInvalidTags, ['']); +}); + +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 text

'); + 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(''), 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. + ${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