diff --git a/.github/scripts/sync-untranslated-issue.mjs b/.github/scripts/sync-untranslated-issue.mjs index 0a63200d12..d3a5595ac5 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,74 @@ 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; - } - } +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} 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 }; } @@ -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 @@ -197,7 +208,7 @@ function generateIssueBody(filesData, checkoutIssuesMap) { **最終更新**: ${new Date().toISOString()} **未翻訳ファイル数**: ${count}件 - +${formatOrphanedNote(filesData.orphaned)} --- `; @@ -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'; } @@ -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); 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: 未翻訳ドキュメント一覧'), []); + }); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80d5bd59f3..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' @@ -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: @@ -28,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 f01494a5af..309b43b6b0 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: @@ -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: @@ -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}); diff --git a/package.json b/package.json index 1c03170f02..a6bbb3518c 100644 --- a/package.json +++ b/package.json @@ -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" 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 new file mode 100644 index 0000000000..e68a5a0eb9 --- /dev/null +++ b/tools/lib/content-routes.ts @@ -0,0 +1,154 @@ +/** + * @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' +); + +/** + * 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. + 'src/content/error.md', +]; + +/** + * 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. + '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; + +/** + * 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); + } +} + +/** + * 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; + +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; + } + } + 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 parseContentRouteMap().` + ); + } + return routes; +} + +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$/, ''); +} + +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 dead upstream content and should not be tracked. */ + orphaned: boolean; +} + +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 }; + + // 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/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..15a4d46ebb --- /dev/null +++ b/tools/verify-content-routes.ts @@ -0,0 +1,64 @@ +#!/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 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 (undeclared.length) { + consola.error( + `${undeclared.length} pages resolve to no URL:\n${undeclared + .map((f) => ` ${f}`) + .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) { + 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 (undeclared.length || stale.length) { + process.exit(1); + } + + consola.success( + `All ${files.length} pages accounted for (${declared.length} declared exceptions).` + ); +} + +main().catch((error) => { + consola.error(error); + process.exit(1); +});