diff --git a/.github/scripts/sync-untranslated-issue.mjs b/.github/scripts/sync-untranslated-issue.mjs new file mode 100644 index 0000000000..2fc4e14a58 --- /dev/null +++ b/.github/scripts/sync-untranslated-issue.mjs @@ -0,0 +1,292 @@ +/** + * @fileoverview GitHub Actions script to sync untranslated files tracking issue + */ + +/** + * @typedef {Object} UntranslatedFile + * @property {string} path - File path relative to adev-ja + * @property {string} category - File category (guide, tutorial, etc.) + * @property {string} extension - File extension without dot + */ + +/** + * @typedef {Object} FilesData + * @property {number} count - Total number of untranslated files + * @property {UntranslatedFile[]} files - Array of untranslated files + */ + +/** + * @typedef {Object} FileLinks + * @property {string} githubUrl - GitHub blob URL + * @property {string|null} previewUrl - Preview URL on angular.jp (null for non-md files) + * @property {string} issueUrl - Issue creation URL with pre-filled title + */ + +/** + * @typedef {Object} GitHubContext + * @property {Object} repo + * @property {string} repo.owner - Repository owner + * @property {string} repo.repo - Repository name + */ + +/** + * @typedef {Object} GitHubAPI + * @property {Object} rest + * @property {Object} rest.issues + * @property {Function} rest.issues.listForRepo + * @property {Function} rest.issues.create + * @property {Function} rest.issues.update + */ + +/** + * @typedef {Object} ActionsCore + * @property {Function} info - Log info message + */ + +const ISSUE_TITLE = 'Tracking: 未翻訳ドキュメント一覧'; +const LABELS = ['type: translation', '翻訳者募集中']; + +/** @type {Record} */ +const CATEGORY_EMOJIS = { + guide: '📖 Guide', + tutorial: '🎓 Tutorial', + reference: '📚 Reference', + 'best-practices': '⚡ Best Practices', + cli: '🔧 CLI', + tools: '🛠️ Tools', + ecosystem: '🌐 Ecosystem', + app: '🧩 Components/App', + other: '📦 その他' +}; + +/** @type {string[]} */ +const CATEGORY_ORDER = ['guide', 'tutorial', 'reference', 'best-practices', 'cli', 'tools', 'ecosystem', 'app', 'other']; + +/** + * Generate URLs for a file + * @param {string} filepath - File path relative to adev-ja + * @returns {FileLinks} Object containing GitHub, preview, and issue URLs + */ +function generateLinks(filepath) { + const githubUrl = `https://github.com/angular/angular-ja/blob/main/adev-ja/${filepath}`; + + // タイトル生成: パスから拡張子を除去したシンプルな形式 + const title = filepath + .replace('src/content/', '') + .replace(/\.(md|ts|html|json)$/, ''); + + const issueUrl = `https://github.com/angular/angular-ja/issues/new?template=----.md&title=${encodeURIComponent(title + ' の翻訳')}`; + + // .mdファイルのみプレビューURL生成 + let previewUrl = null; + if (filepath.endsWith('.md')) { + const previewPath = filepath + .replace('src/content/', '') + .replace(/\/README\.md$/, '') // READMEの場合はディレクトリのみ + .replace(/\.md$/, ''); + previewUrl = `https://angular.jp/${previewPath}`; + } + + return { githubUrl, previewUrl, issueUrl }; +} + +/** + * Format a file entry for the issue body + * @param {string} filepath - File path relative to adev-ja + * @param {FileLinks} links - Object containing URLs for the file + * @param {number|null} checkoutIssueNumber - Translation Checkout issue number if exists + * @returns {string} Markdown formatted list item + */ +function formatFileEntry(filepath, links, checkoutIssueNumber = null) { + const displayName = filepath.replace('src/content/', ''); + + let linksText = `[GitHub](${links.githubUrl})`; + if (links.previewUrl) { + linksText += ` | [プレビュー](${links.previewUrl})`; + } + + if (checkoutIssueNumber) { + linksText += ` | #${checkoutIssueNumber}`; + return `- [x] ${displayName} (${linksText})`; + } else { + linksText += ` | [📝 翻訳宣言](${links.issueUrl})`; + return `- [ ] ${displayName} (${linksText})`; + } +} + +/** + * Group files by category + * @param {UntranslatedFile[]} files - Array of untranslated files + * @returns {Record} Files grouped by category + */ +function groupByCategory(files) { + const groups = {}; + for (const file of files) { + const category = file.category; + if (!groups[category]) { + groups[category] = []; + } + groups[category].push(file); + } + return groups; +} + +/** + * Generate issue body + * @param {FilesData} filesData - Object containing untranslated files data + * @param {Map} checkoutIssuesMap - Map of file paths to issue numbers + * @returns {string} Markdown formatted issue body + */ +function generateIssueBody(filesData, checkoutIssuesMap) { + const { count, files } = filesData; + + if (count === 0) { + return `## 🎉 全てのファイルが翻訳されました! + +**最終更新**: ${new Date().toISOString()} + +現在、未翻訳のファイルはありません。素晴らしい貢献をありがとうございます! + +--- + +## 📝 翻訳ガイド + +今後新しい未翻訳ファイルが追加された場合、このIssueが自動的に更新されます。 + +- [翻訳ガイドライン](https://github.com/angular/angular-ja/blob/main/CONTRIBUTING.md) +`; + } + + const groups = groupByCategory(files); + + let body = `## 📋 未翻訳ドキュメント一覧 + +このIssueは自動的に更新されます。翻訳したいファイルの「📝 翻訳宣言」リンクから翻訳宣言Issueを作成してください。 + +**最終更新**: ${new Date().toISOString()} +**未翻訳ファイル数**: ${count}件 + +--- + +`; + + // カテゴリ順にセクションを生成 + for (const category of CATEGORY_ORDER) { + if (!groups[category] || groups[category].length === 0) continue; + + const categoryFiles = groups[category]; + const emoji = CATEGORY_EMOJIS[category] || category; + + body += `### ${emoji} (${categoryFiles.length}件)\n\n`; + + for (const file of categoryFiles) { + const links = generateLinks(file.path); + const checkoutIssueNumber = checkoutIssuesMap.get(file.path) || null; + body += formatFileEntry(file.path, links, checkoutIssueNumber) + '\n'; + } + + body += '\n'; + } + + body += `--- + +## 📝 翻訳の始め方 + +1. 上記リストから翻訳したいファイルを選ぶ +2. 「📝 翻訳宣言」リンクをクリックしてIssueを作成 +3. [翻訳ガイド](https://github.com/angular/angular-ja/blob/main/CONTRIBUTING.md)に従って作業開始 +`; + + return body; +} + +/** + * Main function + * @param {Object} params - Parameters + * @param {GitHubAPI} params.github - GitHub API instance + * @param {GitHubContext} params.context - GitHub Actions context + * @param {ActionsCore} params.core - GitHub Actions core utilities + * @param {FilesData} params.filesData - Untranslated files data + * @returns {Promise} + */ +export default async ({github, context, core, filesData}) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + + core.info(`Processing ${filesData.count} untranslated files...`); + + // Translation Checkout ラベルの全Issue (open only) を取得 + const { data: checkoutIssues } = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'open', + labels: 'type: Translation Checkout' + }); + + core.info(`Found ${checkoutIssues.length} Translation Checkout issues`); + + // Issueタイトルからファイルパスを抽出してマップを作成 + // タイトル形式: "{ファイルパス} の翻訳" + // 前方一致でマッチング(ディレクトリ名での宣言に対応) + const checkoutIssuesMap = new Map(); + for (const issue of checkoutIssues) { + const match = issue.title.match(/^(.+)\s+の翻訳$/); + if (match) { + const declaredPath = `src/content/${match[1]}`; + // 各未翻訳ファイルに対して前方一致チェック + for (const file of filesData.files) { + if (file.path.startsWith(declaredPath)) { + checkoutIssuesMap.set(file.path, issue.number); + } + } + } + } + + core.info(`Mapped ${checkoutIssuesMap.size} files to checkout issues`); + + // 既存のトラッキングIssueを検索 (state: all で closed も含む) + const { data: issues } = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'all', + labels: LABELS[0], + creator: 'github-actions[bot]' + }); + + const trackingIssue = issues.find(issue => issue.title === ISSUE_TITLE); + + const issueBody = generateIssueBody(filesData, checkoutIssuesMap); + + if (trackingIssue) { + core.info(`Found existing tracking issue #${trackingIssue.number}`); + + // Issueを更新 (タイトルも更新して新しい形式に移行) + await github.rest.issues.update({ + owner, + repo, + issue_number: trackingIssue.number, + title: ISSUE_TITLE, + body: issueBody, + state: 'open' // closed状態の場合はreopen + }); + + core.info(`Updated tracking issue #${trackingIssue.number}`); + + if (trackingIssue.state === 'closed') { + core.info(`Reopened tracking issue #${trackingIssue.number}`); + } + } else { + // 新規Issueを作成 + const { data: newIssue } = await github.rest.issues.create({ + owner, + repo, + title: ISSUE_TITLE, + body: issueBody, + labels: LABELS + }); + + core.info(`Created new tracking issue #${newIssue.number}`); + } + + core.info('Done!'); +}; diff --git a/.github/workflows/sync-untranslated-issue.yml b/.github/workflows/sync-untranslated-issue.yml new file mode 100644 index 0000000000..f01494a5af --- /dev/null +++ b/.github/workflows/sync-untranslated-issue.yml @@ -0,0 +1,47 @@ +name: Sync Untranslated Files Issue + +on: + push: + branches: + - main + issues: + types: [opened, closed, reopened, labeled] + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + update-issue: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: true + + - name: Setup pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # 4.1.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.node-version' + cache: pnpm + + - run: pnpm install + + - name: Get untranslated files + id: files + run: | + FILES_JSON=$(npx tsx tools/list-untranslated.ts --json) + echo "data<> $GITHUB_OUTPUT + echo "$FILES_JSON" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Update tracking issue + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const { default: syncIssue } = await import('${{ github.workspace }}/.github/scripts/sync-untranslated-issue.mjs'); + const filesData = JSON.parse(`${{ steps.files.outputs.data }}`); + await syncIssue({github, context, core, filesData}); diff --git a/tools/list-untranslated.ts b/tools/list-untranslated.ts index 853b1701f9..c15c32b2da 100755 --- a/tools/list-untranslated.ts +++ b/tools/list-untranslated.ts @@ -10,7 +10,21 @@ import { extname, resolve } from 'node:path'; import { exists, getEnFilePath, glob } from './lib/fsutils'; import { adevJaDir } from './lib/workspace'; +function categorizeFile(filepath: string): string { + if (filepath.startsWith('src/content/guide/')) return 'guide'; + if (filepath.startsWith('src/content/tutorials/')) return 'tutorial'; + if (filepath.startsWith('src/content/reference/')) return 'reference'; + if (filepath.startsWith('src/content/best-practices/')) return 'best-practices'; + if (filepath.startsWith('src/content/cli/')) return 'cli'; + if (filepath.startsWith('src/content/tools/')) return 'tools'; + if (filepath.startsWith('src/content/ecosystem/')) return 'ecosystem'; + if (filepath.startsWith('src/app/') || filepath.startsWith('src/shared-docs/')) return 'app'; + return 'other'; +} + async function main() { + const jsonOutput = process.argv.includes('--json'); + const files = await glob(['**/*.{md,ts,html,json}', '!**/license.md'], { cwd: adevJaDir, }); @@ -19,19 +33,33 @@ async function main() { for (const file of files) { const ext = extname(file); if (file.includes(`.en${ext}`)) continue; + // tutorialのconfig.jsonは除外 + if (file.startsWith('src/content/tutorials/') && file.endsWith('config.json')) continue; if (!(await exists(resolve(adevJaDir, getEnFilePath(file))))) { untranslated.push(file); } } - untranslated.length - ? consola.info( - `Found ${untranslated.length} untranslated files:\n${untranslated - .sort() - .map((f) => ` ${f}`) - .join('\n')}` - ) - : consola.success('All files translated! 🎉'); + if (jsonOutput) { + const output = { + count: untranslated.length, + files: untranslated.sort().map(file => ({ + path: file, + category: categorizeFile(file), + extension: extname(file).slice(1) + })) + }; + console.log(JSON.stringify(output, null, 2)); + } else { + untranslated.length + ? consola.info( + `Found ${untranslated.length} untranslated files:\n${untranslated + .sort() + .map((f) => ` ${f}`) + .join('\n')}` + ) + : consola.success('All files translated! 🎉'); + } } main().catch((error) => {