diff --git a/.github/actions/translation-tracker/README.md b/.github/actions/translation-tracker/README.md new file mode 100644 index 0000000000..30c5ec4e57 --- /dev/null +++ b/.github/actions/translation-tracker/README.md @@ -0,0 +1,118 @@ +# p5.js Translation Tracker + +Automatically tracks translation status for p5.js website examples, creates GitHub issues for outdated translations, and shows banners on the website. + +## Features + +- Detects outdated/missing translations using Git commit comparison +- Creates GitHub issues with diff snippets and action checklists +- Shows localized banners on outdated translation pages +- Supports Spanish, Hindi, Korean, and Chinese Simplified + +## File Structure + +``` +.github/actions/translation-tracker/ +├── index.js # Main tracker logic +├── package.json # Dependencies +├── test-local.js # Local testing + +src/layouts/ExampleLayout.astro # Banner integration +src/components/OutdatedTranslationBanner/ # Banner component +public/translation-status/examples.json # Generated status (build artifact) +``` + +## Usage + +### Local Testing +```bash +cd .github/actions/translation-tracker && npm install +node test-local.js +``` + +### Scan All Files (File-based) +```bash +node .github/actions/translation-tracker/index.js +``` + +### Scan All Files (GitHub API + Create Issues) +```bash +GITHUB_TOKEN=your_token GITHUB_REPOSITORY=owner/repo node .github/actions/translation-tracker/index.js +``` + +### GitHub Actions Workflow +Create `.github/workflows/translation-sync.yml`: + +```yaml +name: Translation Sync Tracker + +on: + push: + branches: [main, week2] + paths: ['src/content/examples/en/**'] + workflow_dispatch: + +jobs: + track-translation-changes: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install dependencies + run: npm ci + + - name: Install translation tracker dependencies + run: cd .github/actions/translation-tracker && npm install + + - name: Run translation tracker + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/actions/translation-tracker/index.js +``` + +## Environment Variables + +- `GITHUB_TOKEN` - Required for GitHub API and issue creation +- `GITHUB_REPOSITORY` - Format: `owner/repo` (auto-detected in Actions) + +## What It Does + +1. **Scans** English example files for changes +2. **Compares** with translation files using Git commits +3. **Creates** GitHub issues for outdated translations with: + - Diff snippets showing what changed + - Links to files and comparisons + - Action checklist for translators + - Proper labels (`needs translation`, `lang-es`, etc.) +4. **Generates** manifest file for website banner system +5. **Shows** localized banners on outdated translation pages + +## Sample Output + +``` +📝 Checking 61 English example file(s) + +📊 Translation Status Summary: + 🔄 Outdated: 48 + ❌ Missing: 0 + ✅ Up-to-date: 196 + +🎫 GitHub issues created: 12 + - Issue #36: Spanish, Hindi, Korean, Chinese Simplified need updates + - URL: https://github.com/owner/repo/issues/36 + +🗂️ Wrote translation manifest: public/translation-status/examples.json +``` + +## Dependencies + +- `@actions/core`, `@actions/github`, `@octokit/rest` +- Node.js built-ins: `fs`, `path`, `child_process` \ No newline at end of file diff --git a/.github/actions/translation-tracker/index.js b/.github/actions/translation-tracker/index.js new file mode 100644 index 0000000000..aab0d8d2ba --- /dev/null +++ b/.github/actions/translation-tracker/index.js @@ -0,0 +1,823 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { Octokit } = require('@octokit/rest'); + + +function getTranslationPath(englishFilePath, language) { + // Ensure we have a valid English path + if (!englishFilePath.includes('/en/')) { + throw new Error(`Invalid English file path: ${englishFilePath}. Must contain '/en/'`); + } + + // Split path into parts and replace 'en' directory with target language + const pathParts = englishFilePath.split('/'); + const enIndex = pathParts.findIndex(part => part === 'en'); + + if (enIndex === -1) { + throw new Error(`Could not find 'en' directory in path: ${englishFilePath}`); + } + + // Create new path with language replacement + const translationParts = [...pathParts]; + translationParts[enIndex] = language; + + return translationParts.join('/'); +} + +function getExampleSlugFromEnglishPath(englishFilePath) { + // Expecting paths like: src/content/examples/en/.../description.mdx + const prefix = 'src/content/examples/en/'; + if (!englishFilePath.startsWith(prefix)) return null; + let relative = englishFilePath.substring(prefix.length); + // Remove trailing filename (description.mdx or other .mdx) + if (relative.endsWith('/description.mdx')) { + relative = relative.slice(0, -'/description.mdx'.length); + } else if (relative.endsWith('.mdx')) { + relative = relative.slice(0, -'.mdx'.length); + } + return relative; +} + + +const SUPPORTED_LANGUAGES = ['es', 'hi', 'ko', 'zh-Hans']; + + +class GitHubCommitTracker { + constructor(token, owner, repo) { + this.octokit = new Octokit({ auth: token }); + this.owner = owner; + this.repo = repo; + this.currentBranch = this.detectCurrentBranch(); + } + + /** + * Detect the current git branch + */ + detectCurrentBranch() { + try { + // GitHub Actions environment + if (process.env.GITHUB_HEAD_REF) { + return process.env.GITHUB_HEAD_REF; // For pull requests + } + + if (process.env.GITHUB_REF_NAME) { + return process.env.GITHUB_REF_NAME; // For push events + } + + // Git command fallback + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim(); + if (branch && branch !== 'HEAD') { + return branch; + } + } catch (gitError) { + // Silent fallback + } + + // Default fallback + return 'main'; + + } catch (error) { + return 'main'; + } + } + + /** + * Get the last commit for a specific file using GitHub API + */ + async getLastCommit(filePath) { + try { + const { data } = await this.octokit.rest.repos.listCommits({ + owner: this.owner, + repo: this.repo, + sha: this.currentBranch, + path: filePath, + per_page: 1 + }); + + if (data.length > 0) { + return { + sha: data[0].sha, + date: new Date(data[0].commit.committer.date), + message: data[0].commit.message, + author: data[0].commit.author.name, + url: data[0].html_url + }; + } + + return null; + } catch (error) { + console.log(`⚠️ Primary commit lookup failed for ${filePath} on branch '${this.currentBranch}': ${error.message}`); + + // Fallback to main branch if current branch fails + if (this.currentBranch !== 'main') { + try { + const { data } = await this.octokit.rest.repos.listCommits({ + owner: this.owner, + repo: this.repo, + sha: 'main', + path: filePath, + per_page: 1 + }); + + if (data.length > 0) { + return { + sha: data[0].sha, + date: new Date(data[0].commit.committer.date), + message: data[0].commit.message, + author: data[0].commit.author.name, + url: data[0].html_url + }; + } + } catch (fallbackError) { + console.log(`⚠️ Fallback to main branch also failed for ${filePath}: ${fallbackError.message}`); + } + } + + console.log(`❌ Could not get commit info for ${filePath} from any branch`); + return null; + } + } + + /** + * Get a recent diff for a file (head vs previous commit) and return a short patch snippet + */ + async getRecentDiffForFile(filePath) { + try { + // Get latest two commits for this file on current branch + const { data: commits } = await this.octokit.rest.repos.listCommits({ + owner: this.owner, + repo: this.repo, + sha: this.currentBranch, + path: filePath, + per_page: 2, + }); + + if (!commits || commits.length === 0) { + return null; + } + + const headSha = commits[0].sha; + let baseSha = commits.length > 1 ? commits[1].sha : null; + + // If only one commit is found for the file (new file), use the parent of head + if (!baseSha) { + try { + const { data: headCommit } = await this.octokit.rest.repos.getCommit({ + owner: this.owner, + repo: this.repo, + ref: headSha, + }); + baseSha = headCommit.parents && headCommit.parents.length > 0 ? headCommit.parents[0].sha : null; + } catch (parentErr) { + console.log(`⚠️ Could not resolve base commit for diff of ${filePath}: ${parentErr.message}`); + } + } + + if (!baseSha) { + return { + baseSha: null, + headSha, + compareUrl: `https://github.com/${this.owner}/${this.repo}/commit/${headSha}`, + patchSnippet: null, + isTruncated: false, + }; + } + + // Compare the two commits and extract the file patch + const { data: compare } = await this.octokit.rest.repos.compareCommits({ + owner: this.owner, + repo: this.repo, + base: baseSha, + head: headSha, + }); + + const changedFile = (compare.files || []).find((f) => f.filename === filePath); + const patch = changedFile && changedFile.patch ? changedFile.patch : null; + + let patchSnippet = null; + let isTruncated = false; + if (patch) { + const lines = patch.split('\n'); + const maxLines = 80; + if (lines.length > maxLines) { + patchSnippet = lines.slice(0, maxLines).join('\n'); + isTruncated = true; + } else { + patchSnippet = patch; + } + } + + return { + baseSha, + headSha, + compareUrl: `https://github.com/${this.owner}/${this.repo}/compare/${baseSha}...${headSha}`, + patchSnippet, + isTruncated, + }; + } catch (error) { + console.log(`⚠️ Failed to compute diff for ${filePath} on branch '${this.currentBranch}': ${error.message}`); + // Fallback to at least provide a compare link to branch head + return { + baseSha: null, + headSha: null, + compareUrl: `https://github.com/${this.owner}/${this.repo}/blob/${this.currentBranch}/${filePath}`, + patchSnippet: null, + isTruncated: false, + }; + } + } + + /** + * Create a GitHub issue for outdated translation + */ + async createTranslationIssue(englishFile, language, commitInfo) { + const issueTitle = `🌍 Update ${language.toUpperCase()} translation for ${path.basename(englishFile)}`; + const issueBody = this.formatIssueBody(englishFile, language, commitInfo); + + try { + const { data } = await this.octokit.rest.issues.create({ + owner: this.owner, + repo: this.repo, + title: issueTitle, + body: issueBody, + labels: ['translation', `lang-${language}`, 'help wanted'] + }); + + return data; + } catch (error) { + console.error(`❌ Error creating issue:`, error.message); + return null; + } + } + + /** + * Create a single GitHub issue for a file covering multiple languages + */ + async createMultiLanguageTranslationIssue(fileTranslations) { + const englishFile = fileTranslations.englishFile; + const issueTitle = `🌍 Update translations for ${path.basename(englishFile)}`; + // Fetch recent English diff (best-effort) + const englishDiff = await this.getRecentDiffForFile(englishFile); + const issueBody = this.formatMultiLanguageIssueBody(fileTranslations, englishDiff); + + // Create labels: "needs translation" + specific language labels + const labels = ['needs translation', 'help wanted']; + const affectedLanguages = [ + ...fileTranslations.outdatedLanguages.map(l => l.language), + ...fileTranslations.missingLanguages.map(l => l.language) + ]; + + // Add specific language labels (remove duplicates) + const uniqueLanguages = [...new Set(affectedLanguages)]; + uniqueLanguages.forEach(lang => { + labels.push(`lang-${lang}`); + }); + + try { + const { data } = await this.octokit.rest.issues.create({ + owner: this.owner, + repo: this.repo, + title: issueTitle, + body: issueBody, + labels: labels + }); + + return data; + } catch (error) { + console.error(`❌ Error creating multi-language issue:`, error.message); + return null; + } + } + + /** + * Format the issue body with helpful information + */ + formatIssueBody(englishFile, language, commitInfo) { + const translationPath = getTranslationPath(englishFile, language); + const englishCommit = commitInfo.english; + const translationCommit = commitInfo.translation; + + return `## 🌍 Translation Update Needed + +**File**: \`${englishFile}\` +**Language**: ${this.getLanguageDisplayName(language)} +**Translation file**: \`${translationPath}\` +**Branch**: \`${this.currentBranch}\` + +### 📅 Timeline +- **English last updated**: ${englishCommit.date.toLocaleDateString()} by ${englishCommit.author} +- **Translation last updated**: ${translationCommit ? translationCommit.date.toLocaleDateString() + ' by ' + translationCommit.author : 'Never translated'} + +### 🔗 Quick Links +- [📄 Current English file](https://github.com/${this.owner}/${this.repo}/blob/${this.currentBranch}/${englishFile}) +- [📝 Translation file](https://github.com/${this.owner}/${this.repo}/blob/${this.currentBranch}/${translationPath}) +- [🔍 Compare changes](https://github.com/${this.owner}/${this.repo}/compare/${translationCommit ? translationCommit.sha : 'HEAD'}...${englishCommit.sha}) + +### 📋 What to do +1. Review the English changes in the file +2. Update the ${this.getLanguageDisplayName(language)} translation accordingly +3. Maintain the same structure and formatting +4. Test the translation for accuracy and cultural appropriateness + +### 📝 Recent English Changes +**Last commit**: [${englishCommit.message}](${englishCommit.url}) + +--- +*This issue was automatically created by the p5.js Translation Tracker 🤖* +*Need help? Check our [translation guidelines](https://github.com/processing/p5.js-website/blob/main/contributor_docs/translation.md)*`; + } + + /** + * Format the issue body for multi-language updates + */ + formatMultiLanguageIssueBody(fileTranslations, englishDiff) { + const englishFile = fileTranslations.englishFile; + const outdatedLanguages = fileTranslations.outdatedLanguages; + const missingLanguages = fileTranslations.missingLanguages; + + let body = `## 🌍 Translation Update Needed + +**File**: \`${englishFile}\` +**Branch**: \`${this.currentBranch}\` + +### 📅 Timeline +- **Latest English update**: ${fileTranslations.englishCommit.date.toLocaleDateString()} by ${fileTranslations.englishCommit.author} + +`; + + // Outdated translations section + if (outdatedLanguages.length > 0) { + body += `### 🔄 Outdated Translations\n\n`; + outdatedLanguages.forEach(lang => { + const translationPath = lang.translationPath; + body += `- **${this.getLanguageDisplayName(lang.language)}**: Last updated ${lang.commitInfo.translation.date.toLocaleDateString()} by ${lang.commitInfo.translation.author}\n`; + body += ` - [📝 View file](https://github.com/${this.owner}/${this.repo}/blob/${this.currentBranch}/${translationPath})\n`; + body += ` - [🔍 Compare changes](https://github.com/${this.owner}/${this.repo}/compare/${lang.commitInfo.translation.sha}...${lang.commitInfo.english.sha})\n\n`; + }); + } + + // Missing translations section + if (missingLanguages.length > 0) { + body += `### ❌ Missing Translations\n\n`; + missingLanguages.forEach(lang => { + const translationPath = lang.translationPath; + body += `- **${this.getLanguageDisplayName(lang.language)}**: Translation file does not exist\n`; + body += ` - Expected location: \`${translationPath}\`\n\n`; + }); + } + + // Include an English diff snippet if available + if (englishDiff) { + body += `### 🧾 English Changes (Recent)\n\n`; + body += `- [🔍 View full diff](${englishDiff.compareUrl})\n`; + if (englishDiff.patchSnippet) { + body += `\n\n\u0060\u0060\u0060diff\n${englishDiff.patchSnippet}\n\u0060\u0060\u0060\n`; + if (englishDiff.isTruncated) { + body += `\n_(diff truncated — open the full diff link above for all changes)_\n`; + } + } + body += `\n`; + } + + body += `### 🔗 Quick Links +- [📄 Current English file](https://github.com/${this.owner}/${this.repo}/blob/${this.currentBranch}/${englishFile}) + +### ✅ Action Checklist + +**For translators / contributors:** + +- [ ] Review the recent English file changes and the current translations +- [ ] Confirm if translation already reflects the update — close the issue if so +- [ ] Update the translation files accordingly +- [ ] Maintain structure, code blocks, and formatting +- [ ] Ensure translation is accurate and culturally appropriate + +### 📝 Summary of English File Changes +**Last commit**: [${fileTranslations.englishCommit.message}](${fileTranslations.englishCommit.url}) + +${outdatedLanguages.length > 0 || missingLanguages.length > 0 ? `**Change Type**: English file was updated. ${outdatedLanguages.length > 0 ? `${outdatedLanguages.map(l => this.getLanguageDisplayName(l.language)).join(', ')} translation${outdatedLanguages.length > 1 ? 's' : ''} may be outdated.` : ''} ${missingLanguages.length > 0 ? `${missingLanguages.map(l => this.getLanguageDisplayName(l.language)).join(', ')} translation${missingLanguages.length > 1 ? 's are' : ' is'} missing.` : ''}` : ''} + +--- +ℹ️ **Need help?** See our [Translation Guidelines](https://github.com/processing/p5.js-website/blob/main/contributor_docs/translation.md) + +🤖 *This issue was auto-generated by the p5.js Translation Tracker*`; + return body; + } + + /** + * Get display name for language code + */ + getLanguageDisplayName(langCode) { + const languages = { + 'es': 'Spanish (Español)', + 'hi': 'Hindi (हिन्दी)', + 'ko': 'Korean (한국어)', + 'zh-Hans': 'Chinese Simplified (简体中文)' + }; + return languages[langCode] || langCode; + } +} + +/** + * Week 1: Get changed files from git or test files + * This is the core Week 1 functionality that remains unchanged + */ +function getChangedFiles(testFiles = null) { + // Allow passing test files for local development (Week 1 feature) + if (testFiles) { + console.log('🧪 Using provided test files for local testing'); + return testFiles.filter(file => + file.startsWith('src/content/examples/en') && file.endsWith('.mdx') + ); + } + + try { + // Different git commands for different event types + const gitCommand = process.env.GITHUB_EVENT_NAME === 'pull_request' + ? 'git diff --name-only origin/main...HEAD' // Compare with base branch for PRs + : 'git diff --name-only HEAD~1 HEAD'; // Compare with previous commit for pushes + + const changedFilesOutput = execSync(gitCommand, { encoding: 'utf8' }); + const allChangedFiles = changedFilesOutput.trim().split('\n').filter(file => file.length > 0); + + const changedExampleFiles = allChangedFiles.filter(file => + file.startsWith('src/content/examples/en') && file.endsWith('.mdx') + ); + + return changedExampleFiles; + } catch (error) { + console.error('❌ Error getting changed files:', error.message); + return []; + } +} + +/** + * Scan all English example files (for manual scanning) + */ +function getAllEnglishExampleFiles() { + const examplesPath = 'src/content/examples/en'; + const allFiles = []; + + try { + if (!fs.existsSync(examplesPath)) { + console.log(`❌ Examples path does not exist: ${examplesPath}`); + return []; + } + + const scanDirectory = (dir) => { + const items = fs.readdirSync(dir); + items.forEach(item => { + const itemPath = path.join(dir, item); + if (fs.statSync(itemPath).isDirectory()) { + scanDirectory(itemPath); + } else if (item.endsWith('.mdx')) { + allFiles.push(itemPath); + } + }); + }; + + scanDirectory(examplesPath); + console.log(`📊 Found ${allFiles.length} English example files to check`); + return allFiles; + } catch (error) { + console.error('❌ Error scanning English example files:', error.message); + return []; + } +} + + +function fileExists(filePath) { + try { + return fs.existsSync(filePath); + } catch (error) { + return false; + } +} + + +function getFileModTime(filePath) { + try { + return fs.statSync(filePath).mtime; + } catch (error) { + console.log(`⚠️ Could not get file timestamp for ${filePath}: ${error.message}`); + return null; + } +} + + +async function checkTranslationStatus(changedExampleFiles, githubTracker = null, createIssues = false) { + const translationStatus = { + needsUpdate: [], + missing: [], + upToDate: [], + issuesCreated: [], + fileTranslationMap: new Map() + }; + + // Group translation issues by file to create single issues per file + const fileTranslationMap = translationStatus.fileTranslationMap; + + for (const englishFile of changedExampleFiles) { + const fileName = englishFile.split('/').pop(); + + const fileTranslations = { + englishFile, + outdatedLanguages: [], + missingLanguages: [], + upToDateLanguages: [], + englishCommit: null + }; + + for (const language of SUPPORTED_LANGUAGES) { + const translationPath = getTranslationPath(englishFile, language); + const exists = fileExists(translationPath); + + if (!exists) { + const missingItem = { + englishFile, + language, + translationPath, + status: 'missing' + }; + translationStatus.missing.push(missingItem); + fileTranslations.missingLanguages.push(missingItem); + continue; + } + + + if (githubTracker) { + // Get English commit only once per file + if (!fileTranslations.englishCommit) { + fileTranslations.englishCommit = await githubTracker.getLastCommit(englishFile); + } + const englishCommit = fileTranslations.englishCommit; + const translationCommit = await githubTracker.getLastCommit(translationPath); + + if (!englishCommit) { + continue; + } + + if (!translationCommit) { + const missingItem = { + englishFile, + language, + translationPath, + status: 'missing' + }; + translationStatus.missing.push(missingItem); + fileTranslations.missingLanguages.push(missingItem); + continue; + } + + const isOutdated = englishCommit.date > translationCommit.date; + + if (isOutdated) { + const statusItem = { + englishFile, + language, + translationPath, + status: 'outdated', + commitInfo: { + english: englishCommit, + translation: translationCommit + } + }; + + translationStatus.needsUpdate.push(statusItem); + fileTranslations.outdatedLanguages.push(statusItem); + } else { + const upToDateItem = { + englishFile, + language, + translationPath, + status: 'up-to-date' + }; + translationStatus.upToDate.push(upToDateItem); + fileTranslations.upToDateLanguages.push(upToDateItem); + } + } else { + // Week 1: Fallback to file modification time comparison + const englishModTime = getFileModTime(englishFile); + if (!englishModTime) { + console.log(` ⚠️ Could not get modification time for English file`); + continue; + } + + const translationModTime = getFileModTime(translationPath); + const isOutdated = translationModTime < englishModTime; + + if (isOutdated) { + const statusItem = { + englishFile, + language, + translationPath, + status: 'outdated', + englishModTime, + translationModTime + }; + translationStatus.needsUpdate.push(statusItem); + fileTranslations.outdatedLanguages.push(statusItem); + } else { + const upToDateItem = { + englishFile, + language, + translationPath, + status: 'up-to-date' + }; + translationStatus.upToDate.push(upToDateItem); + fileTranslations.upToDateLanguages.push(upToDateItem); + } + } + } + + // Store file translations for potential issue creation + if (fileTranslations.outdatedLanguages.length > 0 || fileTranslations.missingLanguages.length > 0) { + fileTranslationMap.set(englishFile, fileTranslations); + } + } + + // Create single issues per file (covering all affected languages) + if (createIssues && githubTracker) { + for (const [englishFile, fileTranslations] of fileTranslationMap) { + const issue = await githubTracker.createMultiLanguageTranslationIssue(fileTranslations); + if (issue) { + const issueItem = { + englishFile, + affectedLanguages: [ + ...fileTranslations.outdatedLanguages.map(l => l.language), + ...fileTranslations.missingLanguages.map(l => l.language) + ], + issueNumber: issue.number, + issueUrl: issue.html_url + }; + translationStatus.issuesCreated.push(issueItem); + } + } + } + + return translationStatus; +} + + +// Removed verbose summary function + + +// Remove verbose repository exploration + + +async function main(testFiles = null, options = {}) { + const hasToken = !!process.env.GITHUB_TOKEN; + const isGitHubAction = !!process.env.GITHUB_ACTIONS; // Detect if running in GitHub Actions + const isProduction = hasToken && !testFiles; + + if (testFiles) { + console.log(`🧪 Test mode: Checking ${testFiles.length} predefined files`); + } else if (isGitHubAction) { + console.log(`🚀 GitHub Actions: Checking changed files only`); + } else { + console.log(`🔍 Manual run: Scanning all files`); + } + + // Initialize GitHub tracker if token is available + let githubTracker = null; + if (hasToken) { + try { + const [owner, repo] = (process.env.GITHUB_REPOSITORY || 'processing/p5.js-website').split('/'); + githubTracker = new GitHubCommitTracker(process.env.GITHUB_TOKEN, owner, repo); + console.log(`📡 Connected to ${owner}/${repo}`); + } catch (error) { + console.error('❌ GitHub API failed, using file-based tracking'); + } + } + + // Get files to check + let filesToCheck; + if (testFiles) { + filesToCheck = getChangedFiles(testFiles); + } else if (isGitHubAction) { + filesToCheck = getChangedFiles(); + } else { + console.log('📊 Scanning all English example files...'); + filesToCheck = getAllEnglishExampleFiles(); + } + + if (filesToCheck.length === 0) { + if (isGitHubAction) { + console.log('✅ No English example files changed in this push'); + } else { + console.log('✅ No files to check'); + } + return; + } + + console.log(`📝 Checking ${filesToCheck.length} English example file(s):`); + filesToCheck.forEach(file => console.log(` - ${file}`)); + + const createIssues = isProduction && githubTracker !== null; + const translationStatus = await checkTranslationStatus( + filesToCheck, + githubTracker, + createIssues + ); + + // Detailed results + const { needsUpdate, missing, upToDate, issuesCreated } = translationStatus; + + console.log('\n📊 Translation Status Summary:'); + console.log(` 🔄 Outdated: ${needsUpdate.length}`); + console.log(` ❌ Missing: ${missing.length}`); + console.log(` ✅ Up-to-date: ${upToDate.length}`); + + if (needsUpdate.length > 0) { + console.log('\n🔄 Files needing translation updates:'); + needsUpdate.forEach(item => { + const langName = githubTracker ? githubTracker.getLanguageDisplayName(item.language) : item.language; + if (githubTracker && item.commitInfo) { + console.log(` - ${item.englishFile} → ${langName}`); + console.log(` English: ${item.commitInfo.english.date.toLocaleDateString()} by ${item.commitInfo.english.author}`); + console.log(` Translation: ${item.commitInfo.translation.date.toLocaleDateString()} by ${item.commitInfo.translation.author}`); + } else { + console.log(` - ${item.englishFile} → ${langName}`); + if (item.englishModTime && item.translationModTime) { + console.log(` English: ${item.englishModTime.toLocaleDateString()}`); + console.log(` Translation: ${item.translationModTime.toLocaleDateString()}`); + } + } + }); + } + + if (missing.length > 0) { + console.log('\n❌ Missing translation files:'); + missing.forEach(item => { + const langName = githubTracker ? githubTracker.getLanguageDisplayName(item.language) : item.language; + console.log(` - ${item.englishFile} → ${langName}`); + console.log(` Expected: ${item.translationPath}`); + }); + } + + if (issuesCreated.length > 0) { + console.log(`\n🎫 GitHub issues created: ${issuesCreated.length}`); + issuesCreated.forEach(issue => { + console.log(` - Issue #${issue.issueNumber}: ${issue.englishFile}`); + console.log(` Languages: ${issue.affectedLanguages.map(lang => githubTracker.getLanguageDisplayName(lang)).join(', ')}`); + console.log(` URL: ${issue.issueUrl}`); + }); + } else if (needsUpdate.length > 0 || missing.length > 0) { + if (!hasToken) { + console.log(`\n💡 Run with GITHUB_TOKEN to create GitHub issues`); + } + } + + if (needsUpdate.length === 0 && missing.length === 0) { + console.log('\n✅ All translations are up to date!'); + } + + // Write manifest JSON for the site to consume + try { + const manifestDir = path.join(process.cwd(), 'public', 'translation-status'); + const manifestPath = path.join(manifestDir, 'examples.json'); + if (!fs.existsSync(manifestDir)) { + fs.mkdirSync(manifestDir, { recursive: true }); + } + const examples = {}; + for (const [englishFile, fileTranslations] of translationStatus.fileTranslationMap) { + const slug = getExampleSlugFromEnglishPath(englishFile); + if (!slug) continue; + const outdated = fileTranslations.outdatedLanguages.map(l => l.language); + const missingLangs = fileTranslations.missingLanguages.map(l => l.language); + const upToDateLangs = fileTranslations.upToDateLanguages.map(l => l.language); + examples[slug] = { + englishFile, + outdated, + missing: missingLangs, + upToDate: upToDateLangs, + }; + } + const manifest = { + generatedAt: new Date().toISOString(), + branch: githubTracker ? githubTracker.currentBranch : null, + examples, + }; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); + console.log(`\n🗂️ Wrote translation manifest: ${manifestPath}`); + } catch (writeErr) { + console.log(`\n⚠️ Could not write translation manifest: ${writeErr.message}`); + } +} + +// Export for testing (simplified) +module.exports = { + main, + getChangedFiles, + getAllEnglishExampleFiles, + checkTranslationStatus, + GitHubCommitTracker, + SUPPORTED_LANGUAGES +}; + +// Run if called directly +if (require.main === module) { + main(); +} diff --git a/.github/actions/translation-tracker/package-lock.json b/.github/actions/translation-tracker/package-lock.json new file mode 100644 index 0000000000..cebf63c001 --- /dev/null +++ b/.github/actions/translation-tracker/package-lock.json @@ -0,0 +1,436 @@ +{ + "name": "p5js-translation-tracker", + "version": "0.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "p5js-translation-tracker", + "version": "0.2.0", + "license": "LGPL-2.1", + "dependencies": { + "@actions/core": "^1.10.0", + "@actions/github": "^5.1.1", + "@octokit/rest": "^19.0.5" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/github": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-5.1.1.tgz", + "integrity": "sha512-Nk59rMDoJaV+mHCOJPXuvB1zIbomlKS0dmSIqPGxd0enAXBnOfn4VWF+CGtRCwXZG9Epa54tZA7VIRlJDS8A6g==", + "dependencies": { + "@actions/http-client": "^2.0.1", + "@octokit/core": "^3.6.0", + "@octokit/plugin-paginate-rest": "^2.17.0", + "@octokit/plugin-rest-endpoint-methods": "^5.13.0" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==" + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@octokit/auth-token": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz", + "integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==", + "dependencies": { + "@octokit/types": "^6.0.3" + } + }, + "node_modules/@octokit/core": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", + "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", + "dependencies": { + "@octokit/auth-token": "^2.4.4", + "@octokit/graphql": "^4.5.8", + "@octokit/request": "^5.6.3", + "@octokit/request-error": "^2.0.5", + "@octokit/types": "^6.0.3", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/endpoint": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", + "integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==", + "dependencies": { + "@octokit/types": "^6.0.3", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/graphql": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz", + "integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==", + "dependencies": { + "@octokit/request": "^5.6.0", + "@octokit/types": "^6.0.3", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", + "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "2.21.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.21.3.tgz", + "integrity": "sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw==", + "dependencies": { + "@octokit/types": "^6.40.0" + }, + "peerDependencies": { + "@octokit/core": ">=2" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", + "license": "MIT", + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "5.16.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.16.2.tgz", + "integrity": "sha512-8QFz29Fg5jDuTPXVtey05BLm7OB+M8fnvE64RNegzX7U+5NUXcOcnpTIK0YfSHBg8gYd0oxIq3IZTe9SfPZiRw==", + "dependencies": { + "@octokit/types": "^6.39.0", + "deprecation": "^2.3.1" + }, + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/request": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz", + "integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==", + "dependencies": { + "@octokit/endpoint": "^6.0.1", + "@octokit/request-error": "^2.1.0", + "@octokit/types": "^6.16.1", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/request-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", + "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", + "dependencies": { + "@octokit/types": "^6.0.3", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@octokit/rest": { + "version": "19.0.13", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-19.0.13.tgz", + "integrity": "sha512-/EzVox5V9gYGdbAI+ovYj3nXQT1TtTHRT+0eZPcuC05UFSWO3mdO9UY1C0i2eLF9Un1ONJkAk+IEtYGAC+TahA==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^4.2.1", + "@octokit/plugin-paginate-rest": "^6.1.2", + "@octokit/plugin-request-log": "^1.0.4", + "@octokit/plugin-rest-endpoint-methods": "^7.1.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/auth-token": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-3.0.4.tgz", + "integrity": "sha512-TWFX7cZF2LXoCvdmJWY7XVPi74aSY0+FfBZNSXEXFkMpjcqsQwDSYVv5FhRFaI0V1ECnwbz4j59T/G+rXNWaIQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/core": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.2.4.tgz", + "integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^3.0.0", + "@octokit/graphql": "^5.0.0", + "@octokit/request": "^6.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^9.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/endpoint": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.6.tgz", + "integrity": "sha512-5L4fseVRUsDFGR00tMWD/Trdeeihn999rTMGRMC1G/Ldi1uWlWJzI98H4Iak5DB/RVvQuyMYKqSK/R6mbSOQyg==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^9.0.0", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/graphql": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-5.0.6.tgz", + "integrity": "sha512-Fxyxdy/JH0MnIB5h+UQ3yCoh1FG4kWXfFKkpWqjZHw/p+Kc8Y44Hu/kCgNBT6nU1shNumEchmW/sUO1JuQnPcw==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^6.0.0", + "@octokit/types": "^9.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/openapi-types": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.1.1.tgz", + "integrity": "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==", + "license": "MIT" + }, + "node_modules/@octokit/rest/node_modules/@octokit/plugin-paginate-rest": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-6.1.2.tgz", + "integrity": "sha512-qhrmtQeHU/IivxucOV1bbI/xZyC/iOBhclokv7Sut5vnejAIAEXVcGQeRpQlU39E0WwK9lNvJHphHri/DB6lbQ==", + "license": "MIT", + "dependencies": { + "@octokit/tsconfig": "^1.0.2", + "@octokit/types": "^9.2.3" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@octokit/core": ">=4" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-7.2.3.tgz", + "integrity": "sha512-I5Gml6kTAkzVlN7KCtjOM+Ruwe/rQppp0QU372K1GP7kNOYEKe8Xn5BW4sE62JAHdwpq95OQK/qGNyKQMUzVgA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^10.0.0" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-10.0.0.tgz", + "integrity": "sha512-Vm8IddVmhCgU1fxC1eyinpwqzXPEYu0NrYzD3YZjlGjyftdLBTeqNblRC0jmJmgxbJIsQlyogVeGnrNaaMVzIg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^18.0.0" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/request": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.8.tgz", + "integrity": "sha512-ow4+pkVQ+6XVVsekSYBzJC0VTVvh/FCTUUgTsboGq+DTeWdyIFV8WSCdo0RIxk6wSkBTHqIK1mYuY7nOBXOchw==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^7.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^9.0.0", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/request-error": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", + "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^9.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/types": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.3.2.tgz", + "integrity": "sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^18.0.0" + } + }, + "node_modules/@octokit/tsconfig": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@octokit/tsconfig/-/tsconfig-1.0.2.tgz", + "integrity": "sha512-I0vDR0rdtP8p2lGMzvsJzbhdOWy405HcGovrspJ8RRibHnyRgggUSNO5AIox5LmqiwmatHKYsvj6VGFHkqS7lA==", + "license": "MIT" + }, + "node_modules/@octokit/types": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", + "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", + "dependencies": { + "@octokit/openapi-types": "^12.11.0" + } + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + } + } +} diff --git a/.github/actions/translation-tracker/package.json b/.github/actions/translation-tracker/package.json new file mode 100644 index 0000000000..a6ea6d8bf3 --- /dev/null +++ b/.github/actions/translation-tracker/package.json @@ -0,0 +1,27 @@ +{ + "name": "p5js-translation-tracker", + "version": "0.2.0", + "description": "GitHub Action to track translation status for p5.js examples and documentation", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test": "node test-local.js" + }, + "keywords": [ + "p5.js", + "translation", + "documentation", + "github-actions", + "automation" + ], + "author": "Divyansh Srivastava", + "license": "LGPL-2.1", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@actions/core": "^1.10.0", + "@actions/github": "^5.1.1", + "@octokit/rest": "^19.0.5" + } +} \ No newline at end of file diff --git a/.github/actions/translation-tracker/test-local.js b/.github/actions/translation-tracker/test-local.js new file mode 100644 index 0000000000..50a7a2c993 --- /dev/null +++ b/.github/actions/translation-tracker/test-local.js @@ -0,0 +1,14 @@ + +const { main } = require('./index.js'); + +// Simple test with predefined files +const testFiles = [ + 'src/content/examples/en/01_Shapes_And_Color/00_Shape_Primitives/description.mdx', + 'src/content/examples/en/02_Animation_And_Variables/00_Drawing_Lines/description.mdx', + 'src/content/examples/en/03_Imported_Media/00_Words/description.mdx' +]; + +console.log('🧪 Testing Translation Tracker with predefined files'); +console.log('===================================================='); + +main(testFiles, { createIssues: false }); \ No newline at end of file diff --git a/.github/workflows/translation-sync.yml b/.github/workflows/translation-sync.yml new file mode 100644 index 0000000000..98532faee0 --- /dev/null +++ b/.github/workflows/translation-sync.yml @@ -0,0 +1,38 @@ +name: Translation Sync Tracker + +on: + push: + branches: [main, week2] + paths: + - 'src/content/examples/en/**' + pull_request: + branches: [main, week2] + paths: + - 'src/content/examples/en/**' + workflow_dispatch: + +jobs: + track-translation-changes: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 # Fetch previous commit to compare changes + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install dependencies + run: npm ci + + - name: Install translation tracker dependencies + run: cd .github/actions/translation-tracker && npm install + + - name: Run translation tracker + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/actions/translation-tracker/index.js \ No newline at end of file diff --git a/public/translation-status/examples.json b/public/translation-status/examples.json new file mode 100644 index 0000000000..3466feabd5 --- /dev/null +++ b/public/translation-status/examples.json @@ -0,0 +1,138 @@ +{ + "generatedAt": "2025-08-18T04:46:09.450Z", + "branch": "week2", + "examples": { + "01_Shapes_And_Color/00_Shape_Primitives": { + "englishFile": "src/content/examples/en/01_Shapes_And_Color/00_Shape_Primitives/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "07_Repetition/05_Recursive_Tree": { + "englishFile": "src/content/examples/en/07_Repetition/05_Recursive_Tree/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "09_Angles_And_Motion/00_Sine_Cosine": { + "englishFile": "src/content/examples/en/09_Angles_And_Motion/00_Sine_Cosine/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "10_Games/00_Circle_Clicker": { + "englishFile": "src/content/examples/en/10_Games/00_Circle_Clicker/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "10_Games/01_Ping_Pong": { + "englishFile": "src/content/examples/en/10_Games/01_Ping_Pong/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "11_3D/04_Filter_Shader": { + "englishFile": "src/content/examples/en/11_3D/04_Filter_Shader/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "11_3D/05_Adjusting_Positions_With_A_Shader": { + "englishFile": "src/content/examples/en/11_3D/05_Adjusting_Positions_With_A_Shader/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "12_Advanced_Canvas_Rendering/01_Multiple_Canvases": { + "englishFile": "src/content/examples/en/12_Advanced_Canvas_Rendering/01_Multiple_Canvases/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "12_Advanced_Canvas_Rendering/02_Shader_As_A_Texture": { + "englishFile": "src/content/examples/en/12_Advanced_Canvas_Rendering/02_Shader_As_A_Texture/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "13_Classes_And_Objects/03_Flocking": { + "englishFile": "src/content/examples/en/13_Classes_And_Objects/03_Flocking/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "15_Math_And_Physics/04_Game_Of_Life": { + "englishFile": "src/content/examples/en/15_Math_And_Physics/04_Game_Of_Life/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + }, + "15_Math_And_Physics/05_Mandelbrot": { + "englishFile": "src/content/examples/en/15_Math_And_Physics/05_Mandelbrot/description.mdx", + "outdated": [ + "es", + "hi", + "ko", + "zh-Hans" + ], + "missing": [], + "upToDate": [] + } + } +} \ No newline at end of file diff --git a/src/components/OutdatedTranslationBanner/index.astro b/src/components/OutdatedTranslationBanner/index.astro new file mode 100644 index 0000000000..8cd6226bcc --- /dev/null +++ b/src/components/OutdatedTranslationBanner/index.astro @@ -0,0 +1,90 @@ +--- +interface Props { + englishUrl?: string; + contributeUrl?: string; + title?: string; + message?: string; + locale?: string; +} + +const { + englishUrl = '/en', + contributeUrl = 'https://github.com/processing/p5.js-website/tree/main?tab=readme-ov-file#content-changes', + title, + message, + locale = 'en', +} = Astro.props as Props; + +const copyByLocale: Record = { + 'hi': { + title: 'यह अनुवाद पुराना हो सकता है', + message: 'यह पृष्ठ अंग्रेज़ी संस्करण की तुलना में अद्यतन नहीं है।', + viewEnglish: 'अंग्रेज़ी संस्करण देखें', + contribute: 'अनुवाद में योगदान दें', + }, + 'es': { + title: 'Esta traducción podría estar desactualizada', + message: 'Esta página no está actualizada en comparación con la versión en inglés.', + viewEnglish: 'Ver versión en inglés', + contribute: 'Contribuir a la traducción', + }, + 'ko': { + title: '이 번역은 오래되었을 수 있습니다', + message: '이 페이지는 영어 버전과 비교하여 최신 상태가 아닙니다.', + viewEnglish: '영어 페이지 보기', + contribute: '번역에 기여하기', + }, + 'zh-Hans': { + title: '此翻译可能已过期', + message: '与英文版本相比,此页面不是最新的。', + viewEnglish: '查看英文页面', + contribute: '参与翻译', + }, +}; + +const fallback = { + title: 'This translation might be outdated', + message: 'This page is not updated compared to the English version.', + viewEnglish: 'View English page', + contribute: 'Contribute to translation', +}; + +const copy = copyByLocale[locale] || fallback; +const resolvedTitle = title ?? copy.title; +const resolvedMessage = message ?? copy.message; +--- + +
+ +
+
{resolvedTitle}
+

+ {resolvedMessage} + {copy.viewEnglish} + · + {copy.contribute} +

+
+ + +
+ + diff --git a/src/content/examples/en/01_Shapes_And_Color/00_Shape_Primitives/description.mdx b/src/content/examples/en/01_Shapes_And_Color/00_Shape_Primitives/description.mdx index 428f348709..6ff16675b2 100644 --- a/src/content/examples/en/01_Shapes_And_Color/00_Shape_Primitives/description.mdx +++ b/src/content/examples/en/01_Shapes_And_Color/00_Shape_Primitives/description.mdx @@ -9,7 +9,7 @@ relatedReference: --- This program demonstrates the use of the basic shape -primitive functions +primitive functions. Manual trigger testing: this should be detected by git. square(), rect(), ellipse(), diff --git a/src/layouts/ExampleLayout.astro b/src/layouts/ExampleLayout.astro index cc31a5d2bf..34b3943305 100644 --- a/src/layouts/ExampleLayout.astro +++ b/src/layouts/ExampleLayout.astro @@ -2,7 +2,7 @@ import type { CollectionEntry } from "astro:content"; import Head from "@components/Head/index.astro"; import { setJumpToState } from "../globals/state"; -import { getCurrentLocale, getUiTranslator } from "../i18n/utils"; +import { getCurrentLocale, getUiTranslator, removeLocalePrefix } from "../i18n/utils"; import { generateJumpToState, getRelatedEntriesinCollection, @@ -10,6 +10,9 @@ import { import BaseLayout from "./BaseLayout.astro"; import EditableSketch from "@components/EditableSketch/index.astro"; import RelatedItems from "@components/RelatedItems/index.astro"; +import OutdatedTranslationBanner from "@components/OutdatedTranslationBanner/index.astro"; +import fs from "fs"; +import path from "path"; interface Props { example: CollectionEntry<"examples">; @@ -42,6 +45,49 @@ const relatedReferences = : []; const { Content } = await example.render(); + +// Determine if this example is marked outdated/missing for the current locale +let showBanner = false; +// Build English URL by manually replacing the locale prefix +let englishUrl = Astro.url.pathname; +if (currentLocale !== 'en') { + // Replace /{locale}/ with / to get English URL + englishUrl = englishUrl.replace(`/${currentLocale}/`, '/'); +} + +if (!englishUrl.startsWith('http')) { + englishUrl = `${Astro.url.origin}${englishUrl}`; +} +console.log('DEBUG: Current pathname:', Astro.url.pathname); +console.log('DEBUG: English URL after manual replacement:', englishUrl); +console.log('DEBUG: Current locale:', currentLocale); + +try { + const manifestPath = path.join(process.cwd(), "public", "translation-status", "examples.json"); + if (fs.existsSync(manifestPath)) { + const raw = fs.readFileSync(manifestPath, "utf8"); + const manifest = JSON.parse(raw); + // Derive keys compatible with the manifest + const idNoLocale = (example.id || "").replace(/^[\w-]+\//, ""); + const withoutExt = idNoLocale.replace(/\.(mdx?|ya?ml)$/, ""); + const keyWithDescription = withoutExt; // e.g. 11_3D/00_Geometries/description + const keyWithoutDescription = withoutExt.replace(/\/description$/, ""); // e.g. 11_3D/00_Geometries + const entry = manifest.examples?.[keyWithoutDescription] || manifest.examples?.[keyWithDescription]; + if (entry && currentLocale !== "en") { + const isOutdated = Array.isArray(entry.outdated) && entry.outdated.includes(currentLocale); + const isMissing = Array.isArray(entry.missing) && entry.missing.includes(currentLocale); + showBanner = isOutdated || isMissing; + if (isMissing) { + // If missing, always point to English counterpart + let missingEnglishUrl = Astro.url.pathname.replace(`/${currentLocale}/`, '/'); + englishUrl = `${Astro.url.origin}${missingEnglishUrl}`; + } + } + } +} catch (e) { + console.error(e); + +} --- + {showBanner ? ( + + ) : null}