From b1d8175e5009f28d08cd0beaf1ae450e0878ad90 Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Wed, 12 Aug 2026 21:27:02 +0900 Subject: [PATCH 1/5] fix(tools): resolve doc preview URLs from the navigation route table The untranslated-files tracker derived angular.jp URLs by string-munging content file paths. Since `path` and `contentPath` in the navigation entries are independent, 14 of 132 generated links pointed at 404 pages (e.g. best-practices/performance/overview.md -> /best-practices/performance). Read the route table instead, and cover the routes Bazel generates for the error encyclopedia, extended diagnostics and tutorials. Pages that resolve to no route are unreachable on the site, so they are dropped from the tracker. `pnpm test` now fails when a page stops resolving. --- package.json | 3 +- tools/lib/content-routes.ts | 134 +++++++++++++++++++++++++++++++++ tools/list-untranslated.ts | 51 +++++++++---- tools/verify-content-routes.ts | 67 +++++++++++++++++ 4 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 tools/lib/content-routes.ts create mode 100644 tools/verify-content-routes.ts diff --git a/package.json b/package.json index 1c03170f02..49924806aa 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "start": "tsx tools/watch.ts", "build": "tsx tools/build.ts", "lint": "tsx tools/lint.ts", - "test": "pnpm run test:patch", + "test": "pnpm run test:patch && pnpm run test:routes", "test:patch": "git apply -v --check --directory origin ./tools/adev-patches/*.patch", + "test:routes": "tsx tools/verify-content-routes.ts", "update-origin": "tsx tools/update-origin.ts", "list-untranslated": "tsx tools/list-untranslated.ts", "translate": "tsx --env-file=.env tools/translator/main.ts" diff --git a/tools/lib/content-routes.ts b/tools/lib/content-routes.ts new file mode 100644 index 0000000000..cef6546864 --- /dev/null +++ b/tools/lib/content-routes.ts @@ -0,0 +1,134 @@ +/** + * @fileoverview Resolves adev content files to the URL path they are published at. + * + * The route table is the navigation entries source, where `path` (URL) and + * `contentPath` (source file) are independent values. Deriving a URL from the file + * path alone produces dead links whenever the two diverge. + */ + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { adevJaDir } from './workspace'; + +const navigationEntriesFile = resolve( + adevJaDir, + 'src/app/routing/navigation-entries/index.ts' +); + +/** + * Content files published without a navigable route. They still reach readers, so + * they remain subject to translation. + */ +export const ROUTELESS_TRANSLATABLE_CONTENT: readonly string[] = [ + // Body of the 404 page, rendered by the catch-all route. + 'src/content/error.md', +]; + +/** + * Content files upstream keeps in the repository but no longer routes. They are + * unreachable on the site, so translating them is wasted effort. + */ +export const KNOWN_ORPHANED_CONTENT: readonly string[] = [ + // Superseded by guide/di/creating-and-using-services, kept only as a redirect source. + 'src/content/guide/di/creating-injectable-service.md', + // Dropped from the navigation without a replacement or a redirect. + 'src/content/guide/http/security.md', +]; + +/** + * Routes that Bazel generates at build time (`generate_nav_items` for the error and + * diagnostic encyclopedias, `routes.json` for tutorials) and that therefore never + * appear in the navigation entries source. + */ +const GENERATED_ROUTE_RULES: readonly [RegExp, (match: RegExpMatchArray) => string][] = + [ + [/^reference\/(errors|extended-diagnostics)\/([^/]+)$/, (m) => `${m[1]}/${m[2]}`], + [/^tutorials\/([^/]+)\/intro\/README$/, (m) => `tutorials/${m[1]}`], + [ + /^tutorials\/([^/]+)\/steps\/([^/]+)\/README$/, + (m) => `tutorials/${m[1]}/${m[2]}`, + ], + ]; + +export type ContentRouteMap = ReadonlyMap; + +/** Builds the `contentPath` -> URL path table from the navigation entries source. */ +export async function loadContentRouteMap(): Promise { + const lines = (await readFile(navigationEntriesFile, 'utf-8')).split('\n'); + const routes = new Map(); + + for (let i = 0; i < lines.length; i++) { + const contentPath = lines[i].match(/^\s*contentPath:\s*'([^']+)'/)?.[1]; + if (!contentPath) continue; + + const path = lines[i - 1]?.match(/^\s*path:\s*'([^']+)'/)?.[1]; + if (path === undefined) { + throw new Error( + `${navigationEntriesFile}:${i + 1}: contentPath is not preceded by a path line. ` + + `The navigation entries format changed; update loadContentRouteMap().` + ); + } + routes.set(contentPath, path); + } + + if (routes.size === 0) { + throw new Error( + `No path/contentPath pairs found in ${navigationEntriesFile}. ` + + `The navigation entries format changed; update loadContentRouteMap().` + ); + } + return routes; +} + +/** Returns the `contentPath` of a documentation page, or null for any other file. */ +export function toContentPath(filepath: string): string | null { + if (!filepath.startsWith('src/content/') || !filepath.endsWith('.md')) { + return null; + } + return filepath.slice('src/content/'.length).replace(/\.md$/, ''); +} + +/** Returns the URL path a documentation page is published at, or null if it has none. */ +export function resolveContentRoute( + routes: ContentRouteMap, + filepath: string +): string | null { + const contentPath = toContentPath(filepath); + if (contentPath === null) return null; + + const route = routes.get(contentPath); + if (route !== undefined) return route; + + for (const [pattern, toRoute] of GENERATED_ROUTE_RULES) { + const match = contentPath.match(pattern); + if (match) return toRoute(match); + } + return null; +} + +export interface TranslationTarget { + /** URL path on angular.jp, or null when the file has no page of its own. */ + url: string | null; + /** True when the file is unreachable on the site and should not be tracked. */ + orphaned: boolean; +} + +/** Decides whether a file is worth translating, and where readers can preview it. */ +export function classifyTranslationTarget( + routes: ContentRouteMap, + filepath: string +): TranslationTarget { + // Non-documentation files (app sources, tutorial configs) carry translatable + // strings but have no page of their own. + if (toContentPath(filepath) === null) { + return { url: null, orphaned: false }; + } + + const url = resolveContentRoute(routes, filepath); + if (url !== null) return { url, orphaned: false }; + + return { + url: null, + orphaned: !ROUTELESS_TRANSLATABLE_CONTENT.includes(filepath), + }; +} diff --git a/tools/list-untranslated.ts b/tools/list-untranslated.ts index c15c32b2da..09f3029a78 100755 --- a/tools/list-untranslated.ts +++ b/tools/list-untranslated.ts @@ -7,6 +7,7 @@ import { consola } from 'consola'; import { extname, resolve } from 'node:path'; +import { classifyTranslationTarget, loadContentRouteMap } from './lib/content-routes'; import { exists, getEnFilePath, glob } from './lib/fsutils'; import { adevJaDir } from './lib/workspace'; @@ -15,6 +16,9 @@ function categorizeFile(filepath: string): string { 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/introduction/')) return 'introduction'; + if (filepath.startsWith('src/content/ai/')) return 'ai'; + if (filepath.startsWith('src/content/events/')) return 'events'; if (filepath.startsWith('src/content/cli/')) return 'cli'; if (filepath.startsWith('src/content/tools/')) return 'tools'; if (filepath.startsWith('src/content/ecosystem/')) return 'ecosystem'; @@ -25,40 +29,61 @@ function categorizeFile(filepath: string): string { async function main() { const jsonOutput = process.argv.includes('--json'); + const routes = await loadContentRouteMap(); const files = await glob(['**/*.{md,ts,html,json}', '!**/license.md'], { cwd: adevJaDir, }); const untranslated = []; + const orphaned = []; 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); + if (await exists(resolve(adevJaDir, getEnFilePath(file)))) continue; + + const target = classifyTranslationTarget(routes, file); + // サイト上に到達できないページは翻訳しても読まれないため追跡対象から外す + if (target.orphaned) { + orphaned.push(file); + continue; } + untranslated.push({ + path: file, + category: categorizeFile(file), + extension: ext.slice(1), + url: target.url, + }); } + // ロケール非依存に並べ、環境をまたいでも出力を同一に保つ + untranslated.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + orphaned.sort(); + 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)); + console.log( + JSON.stringify( + { count: untranslated.length, files: untranslated, orphaned }, + null, + 2 + ) + ); } else { untranslated.length ? consola.info( `Found ${untranslated.length} untranslated files:\n${untranslated - .sort() - .map((f) => ` ${f}`) + .map((f) => ` ${f.path}`) .join('\n')}` ) : consola.success('All files translated! 🎉'); + if (orphaned.length) { + consola.warn( + `Skipped ${orphaned.length} files with no route on the site:\n${orphaned + .map((f) => ` ${f}`) + .join('\n')}` + ); + } } } diff --git a/tools/verify-content-routes.ts b/tools/verify-content-routes.ts new file mode 100644 index 0000000000..1d7b45d517 --- /dev/null +++ b/tools/verify-content-routes.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env tsx + +/** + * @fileoverview Verifies that every documentation page resolves to a URL on angular.jp. + * + * Guards the untranslated-files tracking issue against dead preview links: a page that + * stops resolving means either the navigation entries moved, or the route table parser + * broke. Both must be noticed here rather than shipped as 404s. + */ + +import { consola } from 'consola'; +import { + KNOWN_ORPHANED_CONTENT, + ROUTELESS_TRANSLATABLE_CONTENT, + loadContentRouteMap, + resolveContentRoute, +} from './lib/content-routes'; +import { glob } from './lib/fsutils'; +import { adevJaDir } from './lib/workspace'; + +async function main() { + const routes = await loadContentRouteMap(); + const files = await glob( + ['src/content/**/*.md', '!**/*.en.md', '!**/license.md'], + { cwd: adevJaDir } + ); + + const expected = new Set([ + ...ROUTELESS_TRANSLATABLE_CONTENT, + ...KNOWN_ORPHANED_CONTENT, + ]); + const unexpected = files.filter( + (file) => resolveContentRoute(routes, file) === null && !expected.has(file) + ); + const stale = [...expected].filter( + (file) => !files.includes(file) || resolveContentRoute(routes, file) !== null + ); + + if (unexpected.length) { + consola.error( + `${unexpected.length} pages resolve to no URL:\n${unexpected + .map((f) => ` ${f}`) + .join( + '\n' + )}\nEither the page moved in src/app/routing/navigation-entries/index.ts, ` + + `or it is dead upstream content that belongs in KNOWN_ORPHANED_CONTENT.` + ); + } + if (stale.length) { + consola.error( + `${stale.length} entries in tools/lib/content-routes.ts are obsolete ` + + `(the file is gone, or it resolves again):\n${stale.map((f) => ` ${f}`).join('\n')}` + ); + } + if (unexpected.length || stale.length) { + process.exit(1); + } + + consola.success( + `All ${files.length} pages accounted for (${expected.size} known exceptions).` + ); +} + +main().catch((error) => { + consola.error(error); + process.exit(1); +}); From f6dc10f2bda9be5a407a39ff0276f1d4292fbcf0 Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Wed, 12 Aug 2026 21:27:02 +0900 Subject: [PATCH 2/5] fix(ci): correct tracking issue sync matching and pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consume the URL computed by list-untranslated instead of re-deriving it. - Match Translation Checkout titles on path boundaries, so `translate: guide/signals` no longer claims `guide/signals-xyz.md`, and files outside src/content match at all. - Paginate issue queries; the default page size of 30 silently truncated the checkout list and risked creating a duplicate tracking issue. - Pass the file list through the environment rather than interpolating it into the script body. - Add the introduction, ai and events categories, which all fell into "その他". --- .github/scripts/sync-untranslated-issue.mjs | 103 +++++++----------- .github/workflows/sync-untranslated-issue.yml | 9 +- 2 files changed, 47 insertions(+), 65 deletions(-) diff --git a/.github/scripts/sync-untranslated-issue.mjs b/.github/scripts/sync-untranslated-issue.mjs index 0a63200d12..37ffdb2c57 100644 --- a/.github/scripts/sync-untranslated-issue.mjs +++ b/.github/scripts/sync-untranslated-issue.mjs @@ -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 */ @@ -48,76 +50,47 @@ const LABELS = ['type: translation', '翻訳者募集中']; /** @type {Record} */ 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; - } - } - - // チュートリアルの特殊なパス変換 - 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/ を削除 - } - - return basePath; +function toDeclarationKey(filepath) { + return filepath + .replace(/^src\/content\//, '') + .replace(/\.(md|ts|html|json)$/, ''); } /** * 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 }; } @@ -212,7 +185,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'; } @@ -246,30 +219,34 @@ 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: {ファイルパス}" - // 前方一致でマッチング(ディレクトリ名での宣言に対応) + // タイトル形式: "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 match = issue.title.match(/^translate:\s*(.+?)\s*$/); + if (!match) continue; + const declared = toDeclarationKey(match[1]); + for (const file of filesData.files) { + const key = toDeclarationKey(file.path); + if (key === declared || key.startsWith(`${declared}/`)) { + checkoutIssuesMap.set(file.path, issue.number); } } } @@ -277,12 +254,14 @@ export default async ({github, context, core, filesData}) => { 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); diff --git a/.github/workflows/sync-untranslated-issue.yml b/.github/workflows/sync-untranslated-issue.yml index f01494a5af..8d6143cdf7 100644 --- a/.github/workflows/sync-untranslated-issue.yml +++ b/.github/workflows/sync-untranslated-issue.yml @@ -5,7 +5,7 @@ on: branches: - main issues: - types: [opened, closed, reopened, labeled] + types: [opened, closed, reopened, labeled, unlabeled] workflow_dispatch: permissions: @@ -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}); From 1ebff74654cd7e2ef625c819e74fbfc1cc04d0ba Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Wed, 12 Aug 2026 21:46:46 +0900 Subject: [PATCH 3/5] fix(tools): pick the canonical route and stop dropping pages silently Review of the previous commit found three defects in the resolver. The navigation entries list 8 pages under two sections, so one contentPath carries two URLs; taking the last one sent 5 guide pages to their best-practices alias. Prefer the URL that repeats the content path. The parser required contentPath to sit on the line after path, and threw otherwise, so a benign key reordering upstream would have taken down both the sync workflow and CI. Scan the object literals instead. An unresolvable page was assumed orphaned and vanished from tracking. Only pages listed in KNOWN_ORPHANED_CONTENT are dropped now; anything else stays tracked without a preview link, and the route check names both lists so the choice is deliberate. The route check runs as its own CI step: `pnpm test` keeps meaning patch validation, which update-origin depends on. --- .github/workflows/ci.yml | 2 + package.json | 3 +- tools/lib/content-routes.test.ts | 151 +++++++++++++++++++++++++++++++ tools/lib/content-routes.ts | 74 +++++++++------ tools/verify-content-routes.ts | 29 +++--- 5 files changed, 215 insertions(+), 44 deletions(-) create mode 100644 tools/lib/content-routes.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80d5bd59f3..60a87ee463 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: - 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: diff --git a/package.json b/package.json index 49924806aa..a6bbb3518c 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,10 @@ "start": "tsx tools/watch.ts", "build": "tsx tools/build.ts", "lint": "tsx tools/lint.ts", - "test": "pnpm run test:patch && pnpm run test:routes", + "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" diff --git a/tools/lib/content-routes.test.ts b/tools/lib/content-routes.test.ts new file mode 100644 index 0000000000..7873c78955 --- /dev/null +++ b/tools/lib/content-routes.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + classifyTranslationTarget, + parseContentRouteMap, + resolveContentRoute, +} from './content-routes'; + +const nav = (body: string) => parseContentRouteMap(body); + +describe('parseContentRouteMap', () => { + it('pairs path and contentPath within the same object', () => { + const routes = nav(`[ + { + label: 'Overview', + path: 'best-practices/performance', + contentPath: 'best-practices/performance/overview', + }, + ]`); + assert.equal( + routes.get('best-practices/performance/overview'), + 'best-practices/performance' + ); + }); + + it('pairs the keys whatever order they are written in', () => { + const routes = nav(`[ + { + contentPath: 'guide/i18n/overview', + label: 'Overview', + path: 'guide/i18n', + }, + ]`); + assert.equal(routes.get('guide/i18n/overview'), 'guide/i18n'); + }); + + it('keeps a nested child from stealing its parent path', () => { + const routes = nav(`[ + { + label: 'Forms', + path: 'guide/forms', + children: [{label: 'Signals', path: 'guide/forms/signals', contentPath: 'guide/forms/signals/overview'}], + contentPath: 'guide/forms/overview', + }, + ]`); + assert.equal(routes.get('guide/forms/signals/overview'), 'guide/forms/signals'); + assert.equal(routes.get('guide/forms/overview'), 'guide/forms'); + }); + + it('prefers the page own address over a cross-listing, in either order', () => { + const own = `{path: 'guide/ssr', contentPath: 'guide/ssr'}`; + const alias = `{path: 'best-practices/performance/ssr', contentPath: 'guide/ssr'}`; + assert.equal(nav(`[${own}, ${alias}]`).get('guide/ssr'), 'guide/ssr'); + assert.equal(nav(`[${alias}, ${own}]`).get('guide/ssr'), 'guide/ssr'); + }); + + it('ignores an entry that declares no path', () => { + const routes = nav(`[{label: 'Update guide', path: 'update-guide'}, {label: 'Broken', contentPath: 'guide/broken'}]`); + assert.equal(routes.get('guide/broken'), undefined); + }); + + it('is not derailed by braces inside labels', () => { + const routes = nav(`[ + { + label: '{#anchor} の書き方', + path: 'guide/anchors', + contentPath: 'guide/anchors', + }, + ]`); + assert.equal(routes.get('guide/anchors'), 'guide/anchors'); + }); +}); + +describe('resolveContentRoute', () => { + const routes = nav(`[{path: 'errors', contentPath: 'reference/errors/overview'}]`); + + it('resolves Bazel generated routes', () => { + assert.equal( + resolveContentRoute(routes, 'src/content/reference/errors/NG0100.md'), + 'errors/NG0100' + ); + assert.equal( + resolveContentRoute( + routes, + 'src/content/reference/extended-diagnostics/NG8101.md' + ), + 'extended-diagnostics/NG8101' + ); + assert.equal( + resolveContentRoute(routes, 'src/content/tutorials/first-app/intro/README.md'), + 'tutorials/first-app' + ); + assert.equal( + resolveContentRoute( + routes, + 'src/content/tutorials/first-app/steps/06-property-binding/README.md' + ), + 'tutorials/first-app/06-property-binding' + ); + }); + + it('lets the navigation entries win over the generated rules', () => { + assert.equal( + resolveContentRoute(routes, 'src/content/reference/errors/overview.md'), + 'errors' + ); + }); + + it('returns null for files that are not documentation pages', () => { + assert.equal(resolveContentRoute(routes, 'src/app/routing/routes.ts'), null); + assert.equal( + resolveContentRoute(routes, 'src/content/tutorials/signals/intro/config.json'), + null + ); + }); +}); + +describe('classifyTranslationTarget', () => { + const routes = nav(`[{path: 'guide/i18n', contentPath: 'guide/i18n/overview'}]`); + + it('reports the URL of a routed page', () => { + assert.deepEqual( + classifyTranslationTarget(routes, 'src/content/guide/i18n/overview.md'), + { url: 'guide/i18n', orphaned: false } + ); + }); + + it('drops only pages listed as orphaned', () => { + assert.equal( + classifyTranslationTarget( + routes, + 'src/content/guide/di/creating-injectable-service.md' + ).orphaned, + true + ); + }); + + it('keeps tracking a page it cannot classify', () => { + assert.deepEqual(classifyTranslationTarget(routes, 'src/content/guide/new.md'), { + url: null, + orphaned: false, + }); + }); + + it('keeps tracking non-documentation files', () => { + assert.deepEqual(classifyTranslationTarget(routes, 'src/app/routing/routes.ts'), { + url: null, + orphaned: false, + }); + }); +}); diff --git a/tools/lib/content-routes.ts b/tools/lib/content-routes.ts index cef6546864..e68a5a0eb9 100644 --- a/tools/lib/content-routes.ts +++ b/tools/lib/content-routes.ts @@ -16,8 +16,8 @@ const navigationEntriesFile = resolve( ); /** - * Content files published without a navigable route. They still reach readers, so - * they remain subject to translation. + * Pages published without a navigable route. Reviewed and expected: they still reach + * readers, so they stay subject to translation even without a preview link. */ export const ROUTELESS_TRANSLATABLE_CONTENT: readonly string[] = [ // Body of the 404 page, rendered by the catch-all route. @@ -25,8 +25,9 @@ export const ROUTELESS_TRANSLATABLE_CONTENT: readonly string[] = [ ]; /** - * Content files upstream keeps in the repository but no longer routes. They are - * unreachable on the site, so translating them is wasted effort. + * Pages upstream keeps in the repository but no longer routes. They have no page of + * their own, so they are dropped from translation tracking. Note that they are still + * bundled into llms-full.txt, so the drop trades reader-facing value for focus. */ export const KNOWN_ORPHANED_CONTENT: readonly string[] = [ // Superseded by guide/di/creating-and-using-services, kept only as a redirect source. @@ -52,35 +53,57 @@ const GENERATED_ROUTE_RULES: readonly [RegExp, (match: RegExpMatchArray) => stri export type ContentRouteMap = ReadonlyMap; -/** Builds the `contentPath` -> URL path table from the navigation entries source. */ -export async function loadContentRouteMap(): Promise { - const lines = (await readFile(navigationEntriesFile, 'utf-8')).split('\n'); - const routes = new Map(); +/** + * A page may be listed under several sections, giving one `contentPath` several URLs. + * The URL that repeats the content path is the page's own address; the others are + * cross-listings, so they must not displace it. + */ +function addRoute(routes: Map, contentPath: string, path: string) { + const known = routes.get(contentPath); + if (known === undefined || (known !== contentPath && path === contentPath)) { + routes.set(contentPath, path); + } +} - for (let i = 0; i < lines.length; i++) { - const contentPath = lines[i].match(/^\s*contentPath:\s*'([^']+)'/)?.[1]; - if (!contentPath) continue; +/** + * Braces, and the two keys we care about, in source order. Strings and comments are + * matched only so that the scan steps over them without reading their contents. + */ +const NAV_TOKEN = + /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|\/\/[^\n]*|\/\*[\s\S]*?\*\/|\b(path|contentPath)\s*:\s*'((?:[^'\\]|\\.)*)'|([{}])/g; - const path = lines[i - 1]?.match(/^\s*path:\s*'([^']+)'/)?.[1]; - if (path === undefined) { - throw new Error( - `${navigationEntriesFile}:${i + 1}: contentPath is not preceded by a path line. ` + - `The navigation entries format changed; update loadContentRouteMap().` - ); +export function parseContentRouteMap(source: string): ContentRouteMap { + const routes = new Map(); + // Keys belong to the innermost open object, whatever order they are written in. + const stack: { path?: string; contentPath?: string }[] = []; + + for (const [, key, value, brace] of source.matchAll(NAV_TOKEN)) { + if (brace === '{') { + stack.push({}); + } else if (brace === '}') { + const closed = stack.pop(); + if (closed?.path !== undefined && closed.contentPath !== undefined) { + addRoute(routes, closed.contentPath, closed.path); + } + } else if (key !== undefined) { + const entry = stack.at(-1); + if (entry) entry[key as 'path' | 'contentPath'] = value; } - routes.set(contentPath, path); } + return routes; +} +export async function loadContentRouteMap(): Promise { + const routes = parseContentRouteMap(await readFile(navigationEntriesFile, 'utf-8')); if (routes.size === 0) { throw new Error( `No path/contentPath pairs found in ${navigationEntriesFile}. ` + - `The navigation entries format changed; update loadContentRouteMap().` + `The navigation entries format changed; update parseContentRouteMap().` ); } return routes; } -/** Returns the `contentPath` of a documentation page, or null for any other file. */ export function toContentPath(filepath: string): string | null { if (!filepath.startsWith('src/content/') || !filepath.endsWith('.md')) { return null; @@ -88,7 +111,6 @@ export function toContentPath(filepath: string): string | null { return filepath.slice('src/content/'.length).replace(/\.md$/, ''); } -/** Returns the URL path a documentation page is published at, or null if it has none. */ export function resolveContentRoute( routes: ContentRouteMap, filepath: string @@ -109,11 +131,10 @@ export function resolveContentRoute( export interface TranslationTarget { /** URL path on angular.jp, or null when the file has no page of its own. */ url: string | null; - /** True when the file is unreachable on the site and should not be tracked. */ + /** True when the file is dead upstream content and should not be tracked. */ orphaned: boolean; } -/** Decides whether a file is worth translating, and where readers can preview it. */ export function classifyTranslationTarget( routes: ContentRouteMap, filepath: string @@ -127,8 +148,7 @@ export function classifyTranslationTarget( const url = resolveContentRoute(routes, filepath); if (url !== null) return { url, orphaned: false }; - return { - url: null, - orphaned: !ROUTELESS_TRANSLATABLE_CONTENT.includes(filepath), - }; + // Dropping a page needs a deliberate entry. An unclassified page stays tracked, + // so a gap in route resolution is noisy rather than silently destructive. + return { url: null, orphaned: KNOWN_ORPHANED_CONTENT.includes(filepath) }; } diff --git a/tools/verify-content-routes.ts b/tools/verify-content-routes.ts index 1d7b45d517..15a4d46ebb 100644 --- a/tools/verify-content-routes.ts +++ b/tools/verify-content-routes.ts @@ -25,25 +25,22 @@ async function main() { { cwd: adevJaDir } ); - const expected = new Set([ - ...ROUTELESS_TRANSLATABLE_CONTENT, - ...KNOWN_ORPHANED_CONTENT, - ]); - const unexpected = files.filter( - (file) => resolveContentRoute(routes, file) === null && !expected.has(file) - ); - const stale = [...expected].filter( + const declared = [...ROUTELESS_TRANSLATABLE_CONTENT, ...KNOWN_ORPHANED_CONTENT]; + const unrouted = files.filter((file) => resolveContentRoute(routes, file) === null); + const undeclared = unrouted.filter((file) => !declared.includes(file)); + const stale = declared.filter( (file) => !files.includes(file) || resolveContentRoute(routes, file) !== null ); - if (unexpected.length) { + if (undeclared.length) { consola.error( - `${unexpected.length} pages resolve to no URL:\n${unexpected + `${undeclared.length} pages resolve to no URL:\n${undeclared .map((f) => ` ${f}`) - .join( - '\n' - )}\nEither the page moved in src/app/routing/navigation-entries/index.ts, ` + - `or it is dead upstream content that belongs in KNOWN_ORPHANED_CONTENT.` + .join('\n')}\n` + + `If a page moved, fix src/app/routing/navigation-entries/index.ts. Otherwise decide ` + + `in tools/lib/content-routes.ts: ROUTELESS_TRANSLATABLE_CONTENT keeps it in the ` + + `tracking issue without a preview link, KNOWN_ORPHANED_CONTENT removes it from ` + + `translation tracking for good.` ); } if (stale.length) { @@ -52,12 +49,12 @@ async function main() { `(the file is gone, or it resolves again):\n${stale.map((f) => ` ${f}`).join('\n')}` ); } - if (unexpected.length || stale.length) { + if (undeclared.length || stale.length) { process.exit(1); } consola.success( - `All ${files.length} pages accounted for (${expected.size} known exceptions).` + `All ${files.length} pages accounted for (${declared.length} declared exceptions).` ); } From 29720b8af2fcd9749f92fb17727b3e08d7b046f9 Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Wed, 12 Aug 2026 21:46:54 +0900 Subject: [PATCH 4/5] fix(ci): match directory declarations again and disclose skipped files The path-boundary fix broke declarations written with a trailing slash (`translate: guide/di/`), which the old prefix match did handle. Normalise the trailing slash, and cover the matching with tests. Files dropped for having no page now appear in the issue body; leaving them only in the workflow log means nobody notices they went untranslated. --- .github/scripts/sync-untranslated-issue.mjs | 60 +++++++++++++------ .../scripts/sync-untranslated-issue.test.mjs | 60 +++++++++++++++++++ 2 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 .github/scripts/sync-untranslated-issue.test.mjs diff --git a/.github/scripts/sync-untranslated-issue.mjs b/.github/scripts/sync-untranslated-issue.mjs index 37ffdb2c57..d3a5595ac5 100644 --- a/.github/scripts/sync-untranslated-issue.mjs +++ b/.github/scripts/sync-untranslated-issue.mjs @@ -73,10 +73,37 @@ const CATEGORY_ORDER = ['introduction', 'guide', 'tutorial', 'reference', 'best- * @param {string} filepath - File path relative to adev-ja * @returns {string} Declaration key */ -function toDeclarationKey(filepath) { +export function toDeclarationKey(filepath) { return filepath .replace(/^src\/content\//, '') - .replace(/\.(md|ts|html|json)$/, ''); + .replace(/\.(md|ts|html|json)$/, '') + .replace(/\/+$/, ''); // ディレクトリ単位の宣言は末尾に / が付くことがある +} + +/** + * 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} 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 map; } /** @@ -136,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 @@ -170,7 +208,7 @@ function generateIssueBody(filesData, checkoutIssuesMap) { **最終更新**: ${new Date().toISOString()} **未翻訳ファイル数**: ${count}件 - +${formatOrphanedNote(filesData.orphaned)} --- `; @@ -235,21 +273,7 @@ export default async ({github, context, core, filesData}) => { 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*(.+?)\s*$/); - if (!match) continue; - const declared = toDeclarationKey(match[1]); - for (const file of filesData.files) { - const key = toDeclarationKey(file.path); - if (key === declared || key.startsWith(`${declared}/`)) { - checkoutIssuesMap.set(file.path, issue.number); - } - } - } + const checkoutIssuesMap = buildCheckoutIssuesMap(checkoutIssues, filesData.files); core.info(`Mapped ${checkoutIssuesMap.size} files to checkout issues`); diff --git a/.github/scripts/sync-untranslated-issue.test.mjs b/.github/scripts/sync-untranslated-issue.test.mjs new file mode 100644 index 0000000000..6f36aaf6a7 --- /dev/null +++ b/.github/scripts/sync-untranslated-issue.test.mjs @@ -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: 未翻訳ドキュメント一覧'), []); + }); +}); From af2c892ca10a15db5c7e9a37f9e3dddccd56764a Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Wed, 12 Aug 2026 22:00:22 +0900 Subject: [PATCH 5/5] fix(ci): correct the pnpm/action-setup version comment The pin points at the commit tag v4.1.0 resolves to, but the comment read 4.1.0, which is not a ref. zizmor's ref-version-mismatch check blocks any PR that touches these workflows. Only the two files this PR edits are corrected; the same comment remains in the workflows it does not touch. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/sync-untranslated-issue.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60a87ee463..9a01783032 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ 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' @@ -30,7 +30,7 @@ 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' diff --git a/.github/workflows/sync-untranslated-issue.yml b/.github/workflows/sync-untranslated-issue.yml index 8d6143cdf7..309b43b6b0 100644 --- a/.github/workflows/sync-untranslated-issue.yml +++ b/.github/workflows/sync-untranslated-issue.yml @@ -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: