Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 70 additions & 67 deletions .github/scripts/sync-untranslated-issue.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@
* @property {string} path - File path relative to adev-ja
* @property {string} category - File category (guide, tutorial, etc.)
* @property {string} extension - File extension without dot
* @property {string|null} url - URL path on angular.jp, null when the file has no page
*/

/**
* @typedef {Object} FilesData
* @property {number} count - Total number of untranslated files
* @property {UntranslatedFile[]} files - Array of untranslated files
* @property {string[]} orphaned - Files skipped because they have no route on the site
*/

/**
* @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|null} previewUrl - Preview URL on angular.jp (null when the file has no page)
* @property {string} issueUrl - Issue creation URL with pre-filled title
*/

Expand Down Expand Up @@ -48,76 +50,74 @@ const LABELS = ['type: translation', '翻訳者募集中'];

/** @type {Record<string, string>} */
const CATEGORY_EMOJIS = {
introduction: '🚀 Introduction',
guide: '📖 Guide',
tutorial: '🎓 Tutorial',
reference: '📚 Reference',
'best-practices': '⚡ Best Practices',
ai: '🤖 AI',
cli: '🔧 CLI',
tools: '🛠️ Tools',
ecosystem: '🌐 Ecosystem',
events: '📅 Events',
app: '🧩 Components/App',
other: '📦 その他'
};

/** @type {string[]} */
const CATEGORY_ORDER = ['guide', 'tutorial', 'reference', 'best-practices', 'cli', 'tools', 'ecosystem', 'app', 'other'];
const CATEGORY_ORDER = ['introduction', 'guide', 'tutorial', 'reference', 'best-practices', 'ai', 'cli', 'tools', 'ecosystem', 'events', 'app', 'other'];

/**
* Generate preview path from file path
* Identify a file the way a Translation Checkout issue title spells it out:
* the path without the src/content/ prefix and without the extension.
* @param {string} filepath - File path relative to adev-ja
* @returns {string} Preview path for angular.jp
* @returns {string} Declaration key
*/
function generatePreviewPath(filepath) {
const basePath = filepath
.replace('src/content/', '')
.replace(/\/README\.md$/, '') // READMEの場合はディレクトリのみ
.replace(/\.md$/, '');

// reference 配下の特殊なパス変換: reference/ プレフィックスを削除
const referenceTopLevelPaths = ['press-kit', 'roadmap', 'cli'];
if (basePath.startsWith('reference/')) {
const subPath = basePath.replace('reference/', '');
// トップレベルパス(press-kit, roadmap, cli)
if (referenceTopLevelPaths.includes(subPath)) {
return subPath;
}
// サブディレクトリパス(errors/*, extended-diagnostics/*)
if (subPath.startsWith('errors/') || subPath.startsWith('extended-diagnostics/')) {
return subPath;
}
}
export function toDeclarationKey(filepath) {
return filepath
.replace(/^src\/content\//, '')
.replace(/\.(md|ts|html|json)$/, '')
.replace(/\/+$/, ''); // ディレクトリ単位の宣言は末尾に / が付くことがある
}

// チュートリアルの特殊なパス変換
if (basePath.startsWith('tutorials/')) {
// tutorials/first-app/intro -> tutorials/first-app
// tutorials/first-app/steps/01-hello-world -> tutorials/first-app/01-hello-world
return basePath
.replace(/\/intro$/, '') // intro ディレクトリを削除
.replace(/\/steps\//, '/'); // steps/ を削除
/**
* Map each untranslated file to the Translation Checkout issue that claims it.
* A declaration may name one file or a whole directory, but it only ever claims
* files under a path boundary — `guide/signals` must not claim `guide/signals-rfc.md`.
* @param {{number: number, title: string}[]} checkoutIssues - Open Translation Checkout issues
* @param {UntranslatedFile[]} files - Untranslated files
* @returns {Map<string, number>} File path to issue number
*/
export function buildCheckoutIssuesMap(checkoutIssues, files) {
const map = new Map();
for (const issue of checkoutIssues) {
// タイトル形式: "translate: {拡張子を除いたパス}"
const match = issue.title.match(/^translate:\s*(\S.*?)\s*$/);
if (!match) continue;
const declared = toDeclarationKey(match[1]);
if (!declared) continue;
for (const file of files) {
const key = toDeclarationKey(file.path);
if (key === declared || key.startsWith(`${declared}/`)) {
map.set(file.path, issue.number);
}
}
}

return basePath;
return map;
}

/**
* Generate URLs for a file
* @param {string} filepath - File path relative to adev-ja
* @param {UntranslatedFile} file - Untranslated file entry
* @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}`;
function generateLinks(file) {
const githubUrl = `https://github.com/angular/angular-ja/blob/main/adev-ja/${file.path}`;

// タイトル生成: パスから拡張子を除去したシンプルな形式
const title = filepath
.replace('src/content/', '')
.replace(/\.(md|ts|html|json)$/, '');
const issueUrl = `https://github.com/angular/angular-ja/issues/new?template=translation-checkout.md&title=${encodeURIComponent('translate: ' + toDeclarationKey(file.path))}`;

const issueUrl = `https://github.com/angular/angular-ja/issues/new?template=translation-checkout.md&title=${encodeURIComponent('translate: ' + title)}`;

// .mdファイルのみプレビューURL生成
const previewUrl = filepath.endsWith('.md')
? `https://angular.jp/${generatePreviewPath(filepath)}`
: null;
// ページを持つファイルのみプレビューURLを生成する
const previewUrl = file.url ? `https://angular.jp/${file.url}` : null;

return { githubUrl, previewUrl, issueUrl };
}
Expand Down Expand Up @@ -163,6 +163,17 @@ function groupByCategory(files) {
return groups;
}

/**
* 追跡から外したファイルを本文に残す。黙って消えると、翻訳されないまま誰にも気づかれない。
* @param {string[]|undefined} orphaned - Files with no page of their own
* @returns {string} Markdown line, empty when nothing was skipped
*/
function formatOrphanedNote(orphaned) {
if (!orphaned?.length) return '';
const list = orphaned.map(f => `\`${f.replace('src/content/', '')}\``).join(', ');
return `**追跡対象外**: ${orphaned.length}件(サイト上にページを持たないため: ${list})\n`;
}

/**
* Generate issue body
* @param {FilesData} filesData - Object containing untranslated files data
Expand Down Expand Up @@ -197,7 +208,7 @@ function generateIssueBody(filesData, checkoutIssuesMap) {

**最終更新**: ${new Date().toISOString()}
**未翻訳ファイル数**: ${count}件

${formatOrphanedNote(filesData.orphaned)}
---

`;
Expand All @@ -212,7 +223,7 @@ function generateIssueBody(filesData, checkoutIssuesMap) {
body += `### ${emoji} (${categoryFiles.length}件)\n\n`;

for (const file of categoryFiles) {
const links = generateLinks(file.path);
const links = generateLinks(file);
const checkoutIssueNumber = checkoutIssuesMap.get(file.path) || null;
body += formatFileEntry(file.path, links, checkoutIssueNumber) + '\n';
}
Expand Down Expand Up @@ -246,43 +257,35 @@ export default async ({github, context, core, filesData}) => {
const repo = context.repo.repo;

core.info(`Processing ${filesData.count} untranslated files...`);
if (filesData.orphaned?.length) {
core.info(`Skipped ${filesData.orphaned.length} files with no route: ${filesData.orphaned.join(', ')}`);
}

// Translation Checkout ラベルの全Issue (open only) を取得
const { data: checkoutIssues } = await github.rest.issues.listForRepo({
// paginate しないと既定の30件で打ち切られ、宣言済みの表示が欠落する
const checkoutIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
labels: 'type: Translation Checkout'
labels: 'type: Translation Checkout',
per_page: 100
});

core.info(`Found ${checkoutIssues.length} Translation Checkout issues`);

// Issueタイトルからファイルパスを抽出してマップを作成
// タイトル形式: "translate: {ファイルパス}"
// 前方一致でマッチング(ディレクトリ名での宣言に対応)
const checkoutIssuesMap = new Map();
for (const issue of checkoutIssues) {
const match = issue.title.match(/^translate:\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);
}
}
}
}
const checkoutIssuesMap = buildCheckoutIssuesMap(checkoutIssues, filesData.files);

core.info(`Mapped ${checkoutIssuesMap.size} files to checkout issues`);

// 既存のトラッキングIssueを検索 (state: all で closed も含む)
const { data: issues } = await github.rest.issues.listForRepo({
// paginate しないとIssue増加に伴いトラッキングIssueを取り逃がし、重複作成に至る
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'all',
labels: LABELS[0],
creator: 'github-actions[bot]'
creator: 'github-actions[bot]',
per_page: 100
});

const trackingIssue = issues.find(issue => issue.title === ISSUE_TITLE);
Expand Down
60 changes: 60 additions & 0 deletions .github/scripts/sync-untranslated-issue.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { buildCheckoutIssuesMap, toDeclarationKey } from './sync-untranslated-issue.mjs';

const files = [
{ path: 'src/content/guide/signals/effect.md' },
{ path: 'src/content/guide/signals-rfc.md' },
{ path: 'src/content/tools/libraries/overview.md' },
{ path: 'src/app/routing/navigation-entries/index.ts' },
];
const claimed = (title) => [...buildCheckoutIssuesMap([{ number: 1, title }], files).keys()];

describe('toDeclarationKey', () => {
it('strips the content prefix, the extension and a trailing slash', () => {
assert.equal(toDeclarationKey('src/content/guide/i18n/overview.md'), 'guide/i18n/overview');
assert.equal(toDeclarationKey('guide/di/'), 'guide/di');
assert.equal(toDeclarationKey('src/app/routing/routes.ts'), 'src/app/routing/routes');
});
});

describe('buildCheckoutIssuesMap', () => {
it('claims the declared file', () => {
assert.deepEqual(claimed('translate: guide/signals/effect'), [
'src/content/guide/signals/effect.md',
]);
});

it('claims every file under a declared directory, with or without a trailing slash', () => {
assert.deepEqual(claimed('translate: tools/libraries'), [
'src/content/tools/libraries/overview.md',
]);
assert.deepEqual(claimed('translate: tools/libraries/'), [
'src/content/tools/libraries/overview.md',
]);
});

it('stops at the path boundary', () => {
assert.deepEqual(claimed('translate: guide/signals'), [
'src/content/guide/signals/effect.md',
]);
});

it('claims files outside src/content', () => {
assert.deepEqual(claimed('translate: src/app/routing/navigation-entries/index'), [
'src/app/routing/navigation-entries/index.ts',
]);
});

it('tolerates a legacy title that keeps the prefix and the extension', () => {
assert.deepEqual(claimed('translate: src/content/guide/signals/effect.md'), [
'src/content/guide/signals/effect.md',
]);
});

it('claims nothing for a title with no path', () => {
assert.deepEqual(claimed('translate:'), []);
assert.deepEqual(claimed('translate: '), []);
assert.deepEqual(claimed('Tracking: 未翻訳ドキュメント一覧'), []);
});
});
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,24 @@ jobs:
with:
submodules: true
- name: setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # 4.1.0
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.node-version'
cache: pnpm
- run: pnpm install
- run: pnpm run lint
- run: pnpm run test
- run: pnpm run test:unit
- run: pnpm run test:routes
build-ubuntu:
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: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.node-version'
Expand Down
11 changes: 7 additions & 4 deletions .github/workflows/sync-untranslated-issue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
branches:
- main
issues:
types: [opened, closed, reopened, labeled]
types: [opened, closed, reopened, labeled, unlabeled]
workflow_dispatch:

permissions:
Expand All @@ -21,7 +21,7 @@ jobs:
submodules: true

- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # 4.1.0
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
Expand All @@ -40,8 +40,11 @@ jobs:

- name: Update tracking issue
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
# スクリプト本文へ展開すると、値に含まれる ` や ${ でJSが壊れる
FILES_DATA: ${{ steps.files.outputs.data }}
with:
script: |
const { default: syncIssue } = await import('${{ github.workspace }}/.github/scripts/sync-untranslated-issue.mjs');
const filesData = JSON.parse(`${{ steps.files.outputs.data }}`);
const { default: syncIssue } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/sync-untranslated-issue.mjs`);
const filesData = JSON.parse(process.env.FILES_DATA);
await syncIssue({github, context, core, filesData});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"lint": "tsx tools/lint.ts",
"test": "pnpm run test:patch",
"test:patch": "git apply -v --check --directory origin ./tools/adev-patches/*.patch",
"test:routes": "tsx tools/verify-content-routes.ts",
"test:unit": "tsx --test tools/lib/*.test.ts .github/scripts/*.test.mjs",
"update-origin": "tsx tools/update-origin.ts",
"list-untranslated": "tsx tools/list-untranslated.ts",
"translate": "tsx --env-file=.env tools/translator/main.ts"
Expand Down
Loading
Loading