diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index b899f4958c2..2437186fae0 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -5,6 +5,9 @@ on: branches: - master +permissions: + contents: write + jobs: deploy: name: Deploy frontend @@ -16,7 +19,86 @@ jobs: workflow_id: 42688838 access_token: ${{ github.token }} - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Check translation label + id: translation-label + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + LABEL_RESULT=$(node .github/workflows/scripts/check-translation-label.js --repo "${{ github.repository }}" --token "$GITHUB_TOKEN" --sha "${{ github.sha }}" --label "docs:translation-impact") + echo "$LABEL_RESULT" >> "$GITHUB_OUTPUT" + + - name: Get changed English documentation files + id: changed-files + if: steps.translation-label.outputs.label_present == 'true' + run: | + set -euo pipefail + BEFORE="${{ github.event.before }}" + if git rev-parse "$BEFORE^{commit}" >/dev/null 2>&1; then + DIFF_BASE="$BEFORE" + else + DIFF_BASE="" + fi + + if [ -n "$DIFF_BASE" ]; then + CHANGED_FILES=$(git diff --name-only "$DIFF_BASE" "${{ github.sha }}" | grep "^frontend/docs/.*\.md$" || true) + else + CHANGED_FILES=$(git ls-tree --name-only -r "${{ github.sha }}" | grep "^frontend/docs/.*\.md$" || true) + fi + + if [ -z "$CHANGED_FILES" ]; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + if [ -n "$DIFF_BASE" ]; then + printf '%s\n' "$CHANGED_FILES" > changed_english_docs_raw.txt + node .github/workflows/scripts/filter-small-doc-changes.js "$DIFF_BASE" "${{ github.sha }}" changed_english_docs_raw.txt changed_english_docs.txt + if [ -s changed_english_docs.txt ]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + else + printf '%s\n' "$CHANGED_FILES" > changed_english_docs.txt + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Mark translations as outdated + if: steps.translation-label.outputs.label_present == 'true' && steps.changed-files.outputs.has_changes == 'true' + run: | + node .github/workflows/scripts/mark-translations-outdated.js + + - name: Check if translations were modified + if: steps.translation-label.outputs.label_present == 'true' && steps.changed-files.outputs.has_changes == 'true' + id: check-changes + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "translations_modified=true" >> "$GITHUB_OUTPUT" + else + echo "translations_modified=false" >> "$GITHUB_OUTPUT" + fi + + - name: Commit changes + if: steps.translation-label.outputs.label_present == 'true' && steps.check-changes.outputs.translations_modified == 'true' + run: | + git config --local user.email "omp-bot@users.noreply.github.com" + git config --local user.name "omp-bot" + git add frontend/i18n/ + git commit -m "Mark translations as potentially outdated (post-merge)" + + - name: Push changes + if: steps.translation-label.outputs.label_present == 'true' && steps.check-changes.outputs.translations_modified == 'true' + run: | + git push origin HEAD:${{ github.ref }} - name: Cache ~/.npm for npm ci uses: actions/cache@v4 diff --git a/.github/workflows/scripts/check-translation-label.js b/.github/workflows/scripts/check-translation-label.js new file mode 100644 index 00000000000..18e0e2e8289 --- /dev/null +++ b/.github/workflows/scripts/check-translation-label.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +const https = require('https'); + +const args = process.argv.slice(2); +const options = { + repo: process.env.GITHUB_REPOSITORY || '', + token: process.env.GITHUB_TOKEN || '', + sha: process.env.GITHUB_SHA || '', + label: '', + pr: '', +}; + +for (let i = 0; i < args.length; i += 2) { + const key = args[i]; + const value = args[i + 1] || ''; + switch (key) { + case '--repo': + options.repo = value; + break; + case '--token': + options.token = value; + break; + case '--sha': + options.sha = value; + break; + case '--label': + options.label = value; + break; + case '--pr': + options.pr = value; + break; + default: + i -= 1; + } +} + +if (!options.repo || !options.token || !options.label) { + console.error('Missing required arguments: repo, token, and label'); + process.exit(1); +} + +const githubRequest = (path, accept) => + new Promise((resolve, reject) => { + const req = https.request( + { + hostname: 'api.github.com', + path, + method: 'GET', + headers: { + Authorization: `Bearer ${options.token}`, + 'User-Agent': 'translations-workflow', + Accept: accept || 'application/vnd.github+json', + }, + }, + res => { + let body = ''; + res.on('data', chunk => { + body += chunk; + }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(body)); + } catch (err) { + reject(err); + } + } else { + reject(new Error(`GitHub API responded with ${res.statusCode}: ${body}`)); + } + }); + } + ); + req.on('error', reject); + req.end(); + }); + +const detectPrNumber = async () => { + if (options.pr) { + return options.pr; + } + if (!options.sha) { + return ''; + } + + try { + const pulls = await githubRequest( + `/repos/${options.repo}/commits/${options.sha}/pulls`, + 'application/vnd.github.groot-preview+json' + ); + if (Array.isArray(pulls) && pulls.length > 0) { + return String(pulls[0].number); + } + } catch (error) { + console.error(`Failed to determine PR number: ${error.message}`); + } + return ''; +}; + +const main = async () => { + try { + const prNumber = await detectPrNumber(); + if (!prNumber) { + process.stdout.write('label_present=false'); + return; + } + + const issue = await githubRequest(`/repos/${options.repo}/issues/${prNumber}`); + const labels = Array.isArray(issue.labels) ? issue.labels.map(label => label.name) : []; + const hasLabel = labels.includes(options.label); + process.stdout.write(`label_present=${hasLabel ? 'true' : 'false'}`); + } catch (error) { + console.error(`Failed to load labels: ${error.message}`); + process.stdout.write('label_present=false'); + } +}; + +main(); diff --git a/.github/workflows/scripts/filter-small-doc-changes.js b/.github/workflows/scripts/filter-small-doc-changes.js new file mode 100644 index 00000000000..dda96f8119e --- /dev/null +++ b/.github/workflows/scripts/filter-small-doc-changes.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const { execSync } = require('child_process'); + +const MEANINGFUL_CHAR_THRESHOLD = 10; // minimum characters to count the change as worthy + +const [, , baseRef, headRef, inputPath, outputPath] = process.argv; + +if (!headRef || !inputPath || !outputPath) { + console.error('Usage: node filter-small-doc-changes.js '); + process.exit(1); +} + +const fileList = fs + .readFileSync(inputPath, 'utf8') + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + +const keptFiles = []; + +const getFileLines = (ref, file) => { + if (!ref) { + return null; + } + + try { + const content = execSync(`git show ${ref}:${file}`, { encoding: 'utf8' }); + return content.replace(/\r/g, '').split('\n'); + } catch { + return null; + } +}; + +const frontmatterEndLine = lines => { + if (!lines || lines[0] !== '---') { + return 0; + } + + for (let i = 1; i < lines.length; i++) { + if (lines[i] === '---') { + return i + 1; + } + } + return 0; +}; + +const isTrivialLine = (lineText, lineNumber, frontmatterLimit) => { + if (lineNumber > 0 && frontmatterLimit > 0 && lineNumber <= frontmatterLimit) { + return true; + } + return lineText.trim().length === 0; +}; + +const levenshtein = (a, b) => { + if (a === b) { + return 0; + } + const lenA = a.length; + const lenB = b.length; + if (lenA === 0) { + return lenB; + } + if (lenB === 0) { + return lenA; + } + + const matrix = Array.from({ length: lenA + 1 }, () => new Array(lenB + 1).fill(0)); + + for (let i = 0; i <= lenA; i++) { + matrix[i][0] = i; + } + for (let j = 0; j <= lenB; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= lenA; i++) { + for (let j = 1; j <= lenB; j++) { + if (a[i - 1] === b[j - 1]) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + 1 + ); + } + } + } + + return matrix[lenA][lenB]; +}; + +const hasMeaningfulDiff = (file, diffOutput) => { + const baseLines = getFileLines(baseRef, file); + const headLines = getFileLines(headRef, file); + + if (!diffOutput.trim()) { + return false; + } + + if (!baseLines) { + return true; + } + + const baseFrontmatterLimit = frontmatterEndLine(baseLines); + const headFrontmatterLimit = frontmatterEndLine(headLines); + + let currentOldLine = 0; + let currentNewLine = 0; + const diffLines = diffOutput.split('\n'); + const pendingRemoved = []; + let totalChangedChars = 0; + + for (const diffLine of diffLines) { + if (diffLine.startsWith('@@')) { + const match = diffLine.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); + if (match) { + currentOldLine = Number(match[1]); + currentNewLine = Number(match[3]); + } + continue; + } + + if (diffLine.startsWith('---') || diffLine.startsWith('+++')) { + continue; + } + + if (diffLine.startsWith('-')) { + const text = baseLines[currentOldLine - 1] ?? ''; + if (!isTrivialLine(text, currentOldLine, baseFrontmatterLimit)) { + pendingRemoved.push(text); + } + currentOldLine++; + continue; + } + + if (diffLine.startsWith('+')) { + const text = headLines?.[currentNewLine - 1] ?? ''; + if (!isTrivialLine(text, currentNewLine, headFrontmatterLimit)) { + if (pendingRemoved.length > 0) { + const removed = pendingRemoved.shift(); + totalChangedChars += levenshtein(removed, text); + } else { + totalChangedChars += text.trim().length; + } + if (totalChangedChars >= MEANINGFUL_CHAR_THRESHOLD) { + return true; + } + } + currentNewLine++; + continue; + } + } + + while (pendingRemoved.length > 0) { + totalChangedChars += pendingRemoved.shift().trim().length; + if (totalChangedChars >= MEANINGFUL_CHAR_THRESHOLD) { + return true; + } + } + + return totalChangedChars >= MEANINGFUL_CHAR_THRESHOLD; +}; + +for (const file of fileList) { + if (!baseRef) { + keptFiles.push(file); + continue; + } + + let diffOutput = ''; + try { + diffOutput = execSync(`git diff ${baseRef} ${headRef} --unified=0 -- ${file}`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch (error) { + diffOutput = error.stdout?.toString() ?? ''; + if (!diffOutput && !error.stdout) { + keptFiles.push(file); + continue; + } + } + + if (hasMeaningfulDiff(file, diffOutput)) { + keptFiles.push(file); + } +} + +fs.writeFileSync(outputPath, keptFiles.join('\n'), 'utf8'); diff --git a/.github/workflows/scripts/mark-translations-outdated.js b/.github/workflows/scripts/mark-translations-outdated.js new file mode 100644 index 00000000000..e4b4b4cde8b --- /dev/null +++ b/.github/workflows/scripts/mark-translations-outdated.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +// Map language codes to their StaleTranslationWarning component names +// When adding a new language, add the mapping here AND in frontend/src/components/templates/index.ts +const WARNING_COMPONENTS = { + 'es': 'StaleTranslationWarningES', + 'pt-BR': 'StaleTranslationWarningPT', + 'ru': 'StaleTranslationWarningRU', + 'fr': 'StaleTranslationWarningFR', +}; + +const DEFAULT_WARNING_COMPONENT = 'StaleTranslationWarning'; + +const WARNING_MARKER = ' line.trim().length > 0); + +if (changedEnglishDocs.length === 0) { + console.log('No changed English docs to process'); + process.exit(0); +} + +const i18nDir = path.join(process.cwd(), 'frontend', 'i18n'); +if (!fs.existsSync(i18nDir)) { + console.log('No i18n directory found'); + process.exit(0); +} + +const languages = fs.readdirSync(i18nDir).filter(item => { + const itemPath = path.join(i18nDir, item); + return fs.statSync(itemPath).isDirectory(); +}); + +console.log(`Found ${languages.length} language directories: ${languages.join(', ')}`); + +let totalFilesMarked = 0; +const markedFiles = []; + +changedEnglishDocs.forEach(englishDocPath => { + const relativePath = englishDocPath.replace('frontend/docs/', ''); + const englishDocLink = `/docs/${relativePath.replace('.md', '')}`; + + console.log(`\nProcessing: ${englishDocPath}`); + console.log(` Relative path: ${relativePath}`); + + languages.forEach(lang => { + const translationPath = path.join( + i18nDir, + lang, + 'docusaurus-plugin-content-docs', + 'current', + relativePath + ); + + if (!fs.existsSync(translationPath)) { + console.log(` [${lang}] Translation does not exist, skipping`); + return; + } + + let content = fs.readFileSync(translationPath, 'utf8'); + + if (content.includes(WARNING_MARKER)) { + console.log(` [${lang}] Already has outdated warning, skipping`); + return; + } + + let frontmatterEnd = 0; + if (content.startsWith('---')) { + const secondDelimiter = content.indexOf('---', 3); + if (secondDelimiter !== -1) { + frontmatterEnd = secondDelimiter + 3; + } + } + + const componentName = WARNING_COMPONENTS[lang] || DEFAULT_WARNING_COMPONENT; + const warningWithLink = `<${componentName} englishDocLink="${englishDocLink}" />\n\n`; + + let updatedContent; + if (frontmatterEnd > 0) { + const frontmatter = content.substring(0, frontmatterEnd); + const restOfContent = content.substring(frontmatterEnd).trimStart(); + updatedContent = `${frontmatter}\n\n${warningWithLink}${restOfContent}`; + } else { + const restOfContent = content.trimStart(); + updatedContent = `${warningWithLink}${restOfContent}`; + } + + fs.writeFileSync(translationPath, updatedContent, 'utf8'); + console.log(` [${lang}] ✓ Marked as outdated`); + totalFilesMarked++; + markedFiles.push(translationPath.replace(process.cwd() + path.sep, '').replace(/\\/g, '/')); + }); +}); + +console.log(`\n✅ Total files marked: ${totalFilesMarked}`); + +if (markedFiles.length > 0) { + const markedFilesPath = path.join(process.cwd(), 'marked_translation_files.txt'); + fs.writeFileSync(markedFilesPath, markedFiles.join('\n'), 'utf8'); + console.log(`Marked files list written to: ${markedFilesPath}`); +} diff --git a/frontend/src/components/templates/index.ts b/frontend/src/components/templates/index.ts index f006d01d9d7..d62d8420c91 100644 --- a/frontend/src/components/templates/index.ts +++ b/frontend/src/components/templates/index.ts @@ -1,6 +1,13 @@ +// When adding a new language translation component: +// 1. Create the component file in translations/{lang}/{component-name}.tsx +// 2. Import it below with the other language-specific imports +// 3. Add it to the templates object export +// 4. For StaleTranslationWarning components, also add the mapping in .github/workflows/scripts/mark-translations-outdated.js + import VersionWarn from "./version-warning"; import LowercaseNote from "./lowercase-note"; import TipNPCCallbacks from "./npc-callbacks-tip"; +import StaleTranslationWarning from "./stale-translation-warning"; import VersionWarnID from "./translations/id/version-warning"; import LowercaseNoteID from "./translations/id/lowercase-note"; @@ -9,10 +16,12 @@ import TipNPCCallbacksID from "./translations/id/npc-callbacks-tip"; import VersionWarnPT_BR from "./translations/pt-BR/version-warning"; import LowercaseNotePT_BR from "./translations/pt-BR/lowercase-note"; import TipNPCCallbacksPT_BR from "./translations/pt-BR/npc-callbacks-tip"; +import StaleTranslationWarningPT_BR from "./translations/pt-BR/stale-translation-warning"; import VersionWarnES from "./translations/es/version-warning"; import LowercaseNoteES from "./translations/es/lowercase-note"; import TipNPCCallbacksES from "./translations/es/npc-callbacks-tip"; +import StaleTranslationWarningES from "./translations/es/stale-translation-warning"; import VersionWarnZH_CN from "./translations/zh-CN/version-warning"; import LowercaseNoteZH_CN from "./translations/zh-CN/lowercase-note"; @@ -30,19 +39,26 @@ import VersionWarnSR from "./translations/sr/version-warning"; import LowercaseNoteSR from "./translations/sr/lowercase-note"; import TipNPCCallbacksSR from "./translations/sr/npc-callbacks-tip"; +import StaleTranslationWarningRU from "./translations/ru/stale-translation-warning"; + +import StaleTranslationWarningFR from "./translations/fr/stale-translation-warning"; + const templates = { VersionWarn, LowercaseNote, TipNPCCallbacks, + StaleTranslationWarning, VersionWarnID, LowercaseNoteID, TipNPCCallbacksID, VersionWarnPT: VersionWarnPT_BR, LowercaseNotePT: LowercaseNotePT_BR, TipNPCCallbacksPT: TipNPCCallbacksPT_BR, + StaleTranslationWarningPT: StaleTranslationWarningPT_BR, VersionWarnES, LowercaseNoteES, TipNPCCallbacksES, + StaleTranslationWarningES, VersionWarnZH_CN, LowercaseNoteZH_CN, TipNPCCallbacksZH_CN, @@ -55,6 +71,8 @@ const templates = { VersionWarnSR, LowercaseNoteSR, TipNPCCallbacksSR, + StaleTranslationWarningRU, + StaleTranslationWarningFR, }; export default templates; diff --git a/frontend/src/components/templates/stale-translation-warning.tsx b/frontend/src/components/templates/stale-translation-warning.tsx new file mode 100644 index 00000000000..a49eed44117 --- /dev/null +++ b/frontend/src/components/templates/stale-translation-warning.tsx @@ -0,0 +1,25 @@ +import Admonition from "../Admonition"; + +export default function StaleTranslationWarning({ + englishDocLink, +}: { + englishDocLink: string; +}) { + return ( + +

+ Translation May Be Outdated +

+

+ The English version of this document was recently updated. This + translation may not reflect those changes yet. +

+

+ Please help keep our translations up to date! If you're fluent in this + language, consider reviewing the{" "} + English version and updating this + translation. +

+
+ ); +} diff --git a/frontend/src/components/templates/translations/es/stale-translation-warning.tsx b/frontend/src/components/templates/translations/es/stale-translation-warning.tsx new file mode 100644 index 00000000000..d5113ec3444 --- /dev/null +++ b/frontend/src/components/templates/translations/es/stale-translation-warning.tsx @@ -0,0 +1,25 @@ +import Admonition from "../../../Admonition"; + +export default function StaleTranslationWarning({ + englishDocLink, +}: { + englishDocLink: string; +}) { + return ( + +

+ La traducción puede estar desactualizada +

+

+ La versión en inglés de este documento se actualizó recientemente. Es + posible que esta traducción aún no refleje esos cambios. +

+

+ ¡Ayuda a mantener nuestras traducciones actualizadas! Si hablas este + idioma con fluidez, considera revisar la{" "} + versión en inglés y actualizar esta + traducción. +

+
+ ); +} diff --git a/frontend/src/components/templates/translations/fr/stale-translation-warning.tsx b/frontend/src/components/templates/translations/fr/stale-translation-warning.tsx new file mode 100644 index 00000000000..fab8f939335 --- /dev/null +++ b/frontend/src/components/templates/translations/fr/stale-translation-warning.tsx @@ -0,0 +1,25 @@ +import Admonition from "../../../Admonition"; + +export default function StaleTranslationWarning({ + englishDocLink, +}: { + englishDocLink: string; +}) { + return ( + +

+ La traduction peut être obsolète +

+

+ La version anglaise de ce document a été mise à jour récemment. + Cette traduction peut ne pas encore refléter ces changements. +

+

+ Aidez-nous à garder nos traductions à jour. Si vous maîtrisez cette + langue, consultez la{" "} + version anglaise et mettez à jour cette + traduction. +

+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/templates/translations/pt-BR/stale-translation-warning.tsx b/frontend/src/components/templates/translations/pt-BR/stale-translation-warning.tsx new file mode 100644 index 00000000000..f6aa2e941cc --- /dev/null +++ b/frontend/src/components/templates/translations/pt-BR/stale-translation-warning.tsx @@ -0,0 +1,25 @@ +import Admonition from "../../../Admonition"; + +export default function StaleTranslationWarning({ + englishDocLink, +}: { + englishDocLink: string; +}) { + return ( + +

+ A tradução pode estar desatualizada +

+

+ A versão em inglês deste documento foi atualizada recentemente. Esta + tradução pode não refletir essas alterações ainda. +

+

+ Ajude-nos a manter nossas traduções atualizadas! Se você é fluente + neste idioma, considere revisar a{" "} + versão em inglês e atualizar esta + tradução. +

+
+ ); +} diff --git a/frontend/src/components/templates/translations/ru/stale-translation-warning.tsx b/frontend/src/components/templates/translations/ru/stale-translation-warning.tsx new file mode 100644 index 00000000000..099e6e9592e --- /dev/null +++ b/frontend/src/components/templates/translations/ru/stale-translation-warning.tsx @@ -0,0 +1,25 @@ +import Admonition from "../../../Admonition"; + +export default function StaleTranslationWarning({ + englishDocLink, +}: { + englishDocLink: string; +}) { + return ( + +

+ Этот перевод может быть устаревшим. +

+

+ Английская версия этой статьи была недавно обновлена. Данный перевод + может всё ещё не отражать эти изменения. +

+

+ Помогите нам поддерживать актуальность переводов! Если вы свободно + владеете английским языком, пожалуйста, рассмотрите возможность + проверки английской версии и обновления + этого перевода. +

+
+ ); +}