From 80c23444c6768626586c6a8b6eb86b3e7561f9ac Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 7 Sep 2023 02:12:11 +0100 Subject: [PATCH] ci: test i18n --- .github/workflows/i18n.yml | 35 ++++++ package.json | 5 +- pnpm-lock.yaml | 23 ++-- scripts/check-i18n.js | 222 +++++++++++++++++++++++++++++++++++++ src/i18n/de.yml | 2 +- src/i18n/en-GB.yml | 2 +- 6 files changed, 274 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/i18n.yml create mode 100644 scripts/check-i18n.js diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml new file mode 100644 index 0000000..200d9a5 --- /dev/null +++ b/.github/workflows/i18n.yml @@ -0,0 +1,35 @@ +name: 'Check localisations' + +on: + workflow_dispatch: + push: + # branches: + # - main + pull_request: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup node + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Validate localisations + run: ./scripts/check-i18n.js diff --git a/package.json b/package.json index 56976f3..6ce4922 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "postinstall": "node scripts/postinstall", "start": "node .", "studio": "npx prisma studio", - "test": "echo \"There's nothing to test\" && exit 1" + "test": "node scripts/check-i18n" }, "commitlint": { "extends": [ @@ -73,7 +73,7 @@ "short-unique-id": "^4.4.4", "spacetime": "^7.4.4", "terminal-link": "^2.1.1", - "yaml": "^1.10.2" + "yaml": "^2.3.2" }, "devDependencies": { "@commitlint/cli": "^17.6.3", @@ -84,6 +84,7 @@ "eslint-plugin-unused-imports": "^2.0.0", "husky": "^8.0.3", "lint-staged": "^13.2.2", + "markdown-table": "^3.0.3", "nodemon": "^2.0.22" }, "optionalDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 582e596..ba710bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,8 +90,8 @@ dependencies: specifier: ^2.1.1 version: 2.1.1 yaml: - specifier: ^1.10.2 - version: 1.10.2 + specifier: ^2.3.2 + version: 2.3.2 optionalDependencies: bufferutil: @@ -132,6 +132,9 @@ devDependencies: lint-staged: specifier: ^13.2.2 version: 13.2.3 + markdown-table: + specifier: ^3.0.3 + version: 3.0.3 nodemon: specifier: ^2.0.22 version: 2.0.22 @@ -2557,7 +2560,7 @@ packages: object-inspect: 1.12.3 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.3.1 + yaml: 2.3.2 transitivePeerDependencies: - enquirer - supports-color @@ -2707,6 +2710,10 @@ packages: engines: {node: '>=8'} dev: true + /markdown-table@3.0.3: + resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} + dev: true + /marked@4.3.0: resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} engines: {node: '>= 12'} @@ -4137,15 +4144,9 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - /yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - dev: false - - /yaml@2.3.1: - resolution: {integrity: sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==} + /yaml@2.3.2: + resolution: {integrity: sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==} engines: {node: '>= 14'} - dev: true /yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} diff --git a/scripts/check-i18n.js b/scripts/check-i18n.js new file mode 100644 index 0000000..e0a92d4 --- /dev/null +++ b/scripts/check-i18n.js @@ -0,0 +1,222 @@ +#!/usr/bin/env node + +/* eslint-disable no-console */ +const fs = require('fs'); +const { + Composer, + LineCounter, + Parser, +} = require('yaml'); + +function resolve(ast, key) { + return key + .split('.') + .reduce( + (acc, part) => acc.value.items.find(item => item.key.source === part), + ast, + ); +} + +const errors = []; +const locales = []; +const files = fs.readdirSync('./src/i18n').filter(file => file.endsWith('.yml')); + +for (const file of files) { + const locale = file.substring(0, file.length - 4); + locales.push(locale); + const content = fs.readFileSync(`./src/i18n/${file}`, 'utf8'); + const lineCounter = new LineCounter(); + const parser = new Parser(lineCounter.addNewLine); + const tokenGenerator = parser.parse(content); + // Unfortunately needs to be parsed twice because `Generator` is single-use? + // Might as well use the simpler YAML.parse() instead of the Composer but I've already written this. + const [ast] = Array.from(parser.parse(content)); + const docs = new Composer().compose(tokenGenerator); + const [doc] = Array.from(docs, doc => doc.toJS()); + + /** + * Message context menu commands + */ + + for (const [key, command] of Object.entries(doc.commands?.message || {})) { + if (command.name?.length > 32) { + const searchKey = `commands.message.${key}.name`; + const { + col, + line, + } = lineCounter.linePos(resolve(ast, searchKey).value.offset); + errors.push({ + col, + line, + locale, + message: `\`${searchKey}\` is too long (${command.name.length} > 32)`, + }); + } + } + + /** + * Chat input (slash) commands + */ + + for (const [key, command] of Object.entries(doc.commands?.slash || {})) { + + const regex = /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u; + + if (command.name) { + if (!regex.test(command.name)) { + const searchKey = `commands.slash.${key}.name`; + const { + col, + line, + } = lineCounter.linePos(resolve(ast, searchKey).value.offset); + errors.push({ + col, + line, + locale, + message: `\`${searchKey}\` does not match the regex pattern \`${regex.toString()}\``, + }); + } + + if (command.name !== command.name.toLocaleLowerCase()) { + const searchKey = `commands.slash.${key}.name`; + const { + col, + line, + } = lineCounter.linePos(resolve(ast, searchKey).value.offset); + errors.push({ + col, + line, + locale, + message: `\`${searchKey}\` is not lowercase`, + }); + } + } + + if (command.description?.length > 100) { + const searchKey = `commands.slash.${key}.description`; + const { + col, + line, + } = lineCounter.linePos(resolve(ast, searchKey).value.offset); + errors.push({ + col, + line, + locale, + message: `\`${searchKey}\` is too long (${command.description.length} > 100)`, + }); + } + + for (const [key2, option] of Object.entries(command.options || {})) { + if (option.name) { + if (!regex.test(option.name)) { + const searchKey = `commands.slash.${key}.options.${key2}.name`; + const { + col, + line, + } = lineCounter.linePos(resolve(ast, searchKey).value.offset); + errors.push({ + col, + line, + locale, + message: `\`${searchKey}\` does not match the regex pattern "${regex.toString()}"`, + }); + } + + if (option.name !== option.name.toLocaleLowerCase()) { + const searchKey = `commands.slash.${key}.options.${key2}.name`; + const { + col, + line, + } = lineCounter.linePos(resolve(ast, searchKey).value.offset); + errors.push({ + col, + line, + locale, + message: `\`${searchKey}\` is not lowercase`, + }); + } + } + + if (option.description?.length > 100) { + const searchKey = `commands.slash.${key}.options.${key2}.description`; + const { + col, + line, + } = lineCounter.linePos(resolve(ast, searchKey).value.offset); + errors.push({ + col, + line, + locale, + message: `\`${searchKey}\` is too long (${option.description.length} > 100)`, + }); + } + } + } + + /** + * User context menu commands + */ + + for (const [key, command] of Object.entries(doc.commands?.user || {})) { + if (command.name?.length > 32) { + const searchKey = `commands.user.${key}.name`; + const { + col, + line, + } = lineCounter.linePos(resolve(ast, searchKey).value.offset); + errors.push({ + col, + line, + locale, + message: `\`${searchKey}\` is too long (${command.name.length} > 32)`, + }); + } + } + +} + +// @actions/core: +// https://github.blog/2022-05-09-supercharging-github-actions-with-job-summaries/ + +async function summarise() { + const { markdownTable } = await import('markdown-table'); + const branch = process.env.GITHUB_SHA || 'main'; + let summary = '# Test results\n\n'; + summary += + markdownTable([ + ['Locale', 'Result'], + ...locales.map(locale => [ + `${locale}`, + errors.filter(error => error.locale === locale).length === 0 ? '✅ Passed' : '❌ Failed', + ]), + ]) + '\n\n'; + + for (const locale of locales) { + // thanks copilot + console.log(`::group::${locale}`); + const localeErrors = errors.filter(error => error.locale === locale); + + summary += `## \`${locale}\`\n\n`; + + if (localeErrors.length > 0) { + for (const error of localeErrors) { + summary += `https://github.com/discord-tickets/bot/blob/${branch}/src/i18n/${locale}.yml#L${error.line}\n\n`; + console.log(`::error file=src/i18n/${locale}.yml,line=${error.line},col=${error.col}::${error.message}`); + } + } else { + summary += 'No errors found\n\n'; + console.log(`::notice file=src/i18n/${locale}.yml::No errors found`); + } + + console.log('::endgroup::'); + } + + fs.writeFileSync(process.env.GITHUB_STEP_SUMMARY || 'summary.md', summary); + + if (errors.length > 0) { + console.error('Failed with %d errors', errors.length); + process.exit(1); + } +} + +summarise(); diff --git a/src/i18n/de.yml b/src/i18n/de.yml index fe494aa..15a7ba7 100644 --- a/src/i18n/de.yml +++ b/src/i18n/de.yml @@ -72,7 +72,7 @@ commands: description: '**Verwende {command} um ein Ticket zu erstellen und Hilfe zu erhalten.**' settings: Bot-Einstellungen - name: hilfe + name: hil fe title: hilfe description: Zeigt das Hilfemenü an force-close: diff --git a/src/i18n/en-GB.yml b/src/i18n/en-GB.yml index e3db6b5..45cadb9 100644 --- a/src/i18n/en-GB.yml +++ b/src/i18n/en-GB.yml @@ -54,7 +54,7 @@ commands: options: member: description: The member to add to the ticket - name: member + name: Member ticket: description: The ticket to add the member to name: ticket