From fd00677264f130b80fc96715d19a054186e1278d Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 13 Aug 2026 23:53:21 -0400 Subject: [PATCH 01/28] chore: add translation verification tooling, ported from angular-ja MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El tooling de este repo deriva de angular/angular-ja, que desde entonces resolvió varios problemas que aquí seguían abiertos. Este commit toma lo aplicable y agrega la detección de traducciones desactualizadas, que angular-ja no cubre. update-origin - Falla si un objetivo de copia no coincide con ningún archivo, en vez de omitirlo en silencio. Es la causa raíz del desfase que #192 corrigió a mano: las rutas cambiaron upstream y nadie se enteró. - Cada objetivo se glob-ea por separado para poder validarlo individualmente. - Excluye readmes de apps de ejemplo y páginas índice sin prosa, siguiendo el criterio de angular-ja. No se excluye kitchen-sink.md, que aquí sí está traducido. - Elimina las copias en inglés de los archivos ahora excluidos: el build superpone adev-es sobre origin, así que dejarlas congelaría esas páginas en una versión vieja en vez de dejar pasar la de origin. check-translations (nuevo) - Detecta traducciones desactualizadas comparando el .en.md del commit donde se tradujo por última vez contra el actual. No necesita metadata: el historial de git ya lo contiene. - Detecta archivos sin traducir (sin .en.md), equivalente al list-untranslated de angular-ja. - Separa cambios de prosa del ruido de formato para que el reporte sea accionable. - Acepta --ref para auditar una rama o un PR antes de mergearlo. lint-glossary (nuevo) - Verifica consistencia terminológica con reglas en glosario.yml, mismo formato expected/pattern que el prh.yml de angular-ja. - Ignora bloques de código, código en línea, enlaces y anchors {#id}. - Solo términos inequívocos: una regla con falsos positivos hace que el linter se ignore. --- UPDATE-ORIGIN.md | 36 +++++ adev-es/src/content/examples/i18n/readme.md | 27 ---- .../src/app/readme.md | 9 -- .../content/reference/concepts/overview.md | 7 - adev-es/src/content/tutorials/README.md | 108 ------------- glosario.yml | 55 +++++++ package.json | 2 + tools/check-translations.mjs | 145 ++++++++++++++++++ tools/lint-glossary.mjs | 112 ++++++++++++++ tools/update-origin.mjs | 54 +++++-- 10 files changed, 388 insertions(+), 167 deletions(-) delete mode 100644 adev-es/src/content/examples/i18n/readme.md delete mode 100644 adev-es/src/content/examples/service-worker-getting-started/src/app/readme.md delete mode 100644 adev-es/src/content/reference/concepts/overview.md delete mode 100644 adev-es/src/content/tutorials/README.md create mode 100644 glosario.yml create mode 100644 tools/check-translations.mjs create mode 100644 tools/lint-glossary.mjs diff --git a/UPDATE-ORIGIN.md b/UPDATE-ORIGIN.md index 667b5e53..f2156e4b 100644 --- a/UPDATE-ORIGIN.md +++ b/UPDATE-ORIGIN.md @@ -36,6 +36,42 @@ Si algún cambio requiere una nueva traducción, se reflejará en el archivo `xx 2. Cree un problema en Github solicitando traducciones para las partes no traducidas. +## Verificar el estado de las traducciones + +Después de actualizar el origen, estos comandos te dicen qué quedó pendiente: + +```shell +npm run check-translations # qué se desincronizó o quedó sin traducir +npm run lint-glossary # consistencia terminológica del español +``` + +### `check-translations` + +Distingue dos problemas que `update-origin` puede dejar atrás: + +- **Desactualizadas** — el archivo ya está traducido (existe su `.en.md`) pero el inglés cambió después. Compara el `.en.md` tal como estaba en el commit donde se tocó por última vez el `.md` contra el `.en.md` actual: ese diff es exactamente lo que falta traducir. Separa los cambios de prosa del ruido de formato para que el reporte sea accionable. +- **Sin traducir** — no existe `.en.md`, así que `update-origin` copió el inglés directamente al `.md`. + +Opciones: + +```shell +npm run check-translations -- --diff # mostrar los diffs completos +npm run check-translations -- --ref= # verificar otra rama o un PR +``` + +Para revisar un PR antes de mergearlo: + +```shell +git fetch origin pull//head:refs/tmp/pr +npm run check-translations -- --ref=refs/tmp/pr +``` + +### `lint-glossary` + +Verifica que se usen los términos acordados. Las reglas viven en [`glosario.yml`](./glosario.yml) con formato `expected` / `pattern`. Ignora bloques de código, código en línea, enlaces y anchors `{#id}`, donde el vocabulario español no aplica. + +Solo contiene términos inequívocos: una regla con falsos positivos hace que el linter se ignore, que es peor que no tenerlo. Los términos que dependen del contexto viven en el glosario completo del skill de traducción. + ### Patrón 3 - Código de aplicación adev Algunos archivos se han modificado para modificar la aplicación angular.dev. diff --git a/adev-es/src/content/examples/i18n/readme.md b/adev-es/src/content/examples/i18n/readme.md deleted file mode 100644 index f72a08f4..00000000 --- a/adev-es/src/content/examples/i18n/readme.md +++ /dev/null @@ -1,27 +0,0 @@ -# Angular i18n Internationalization Example - -This sample comes from the Angular documentation's "[Example Angular Internationalization application](https://angular.dev/guide/i18n/example)" page. - -## Install and Run the Download - -1. `npm install` the node_module packages -2. `npm start` to see it run in English -3. `npm run start:fr` to see it run with French translation. - -> See the scripts in `package.json` for an explanation of these commands. - -## Run in StackBlitz - -StackBlitz compiles and runs the English version by default. - -To see the example translate to French with Angular i18n: - -1. Open the `project.json` file and add the following to the bottom: - -```json - "stackblitz": { - "startCommand": "npm run start:fr" - } -``` - -1. Click the "Fork" button in the StackBlitz header. That makes a new copy for you with this change and re-runs the example in French. diff --git a/adev-es/src/content/examples/service-worker-getting-started/src/app/readme.md b/adev-es/src/content/examples/service-worker-getting-started/src/app/readme.md deleted file mode 100644 index a616d6ae..00000000 --- a/adev-es/src/content/examples/service-worker-getting-started/src/app/readme.md +++ /dev/null @@ -1,9 +0,0 @@ -# Instructions for Angular Universal Example Download - -This is the downloaded sample code for the [Angular Universal (Standalone) guide](https://angular.dev/guide/ssr). - -## Install and Run - -1. `npm install` to install the `node_module` packages -2. `npm run dev:ssr` to launch the server and application -3. Launch the browser to `http://localhost:4200` diff --git a/adev-es/src/content/reference/concepts/overview.md b/adev-es/src/content/reference/concepts/overview.md deleted file mode 100644 index 3dea7294..00000000 --- a/adev-es/src/content/reference/concepts/overview.md +++ /dev/null @@ -1,7 +0,0 @@ -# Concepts - - - - NgModules is a concept that commonly used in architecture v16 and earlier to help configure the injector and the compiler and help organize related things together. - - diff --git a/adev-es/src/content/tutorials/README.md b/adev-es/src/content/tutorials/README.md deleted file mode 100644 index af5d77a0..00000000 --- a/adev-es/src/content/tutorials/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# Angular embedded docs tutorial - -- [Tutorial files](#tutorial-files) -- [Tutorials directory structure](#tutorials-directory-structure) -- [Reserved tutorials directories](#reserved-tutorials-directories) - -## Tutorial files - -The tutorials content consists of the tutorial content, source code and configuration. - -### Content: `README.md` - -The tutorial content must be located in a `README.md` file in the tutorial directory. - -Taking the `learn-angular` tutorial as an example, see: [`src/content/tutorials/learn-angular/intro/README.md`](/src/content/tutorials/learn-angular/intro/README.md) - -### Configuration: `config.json` - -Each tutorial is defined by a `config.json`, which can have the following options: - -- `title`: defines the tutorial title used in the tutorial nav -- `nextTutorial`: the path of the next tutorial (only in `intro/` step) -- `src`: the relative path to an external directory, which defines the tutorial source code used in the embedded editor -- `answerSrc`: the relative path to an external directory, which defines the tutorial answer used in the embedded editor -- `openFiles`: an array of files to be open in the editor -- `type`: the type denotes how the tutorial will be presented and which components are necessary for that tutorial - - `cli`: a tutorial with a `cli` type will contain only the content and an interactive terminal with the Angular CLI - - `editor`: used for the complete embedded editor, containing the code editor, the preview, an interactive terminal and the console with outputs from the dev server - - `local`: disables the embedded editor and shows only the content - - `editor-only`: a special config used for the tutorial playground and the homepage playground, which disables the content and shows only the embedded editor - -### Source code - -The tutorial source code includes every file in the tutorial directory, except `README.md` and `config.json`. - -The tutorial source code has precedence over the [`common`](#common) project file, so if a file exists in both [`common`](#common) and in the tutorial directory, containing the same relative path, the tutorial file will override the [`common`](#common) file. - -## Tutorials directory structure - -A tutorial is composed of an introduction and steps. Both the intro and each step contains its own content, config and source code. - -Taking the `learn-angular` tutorial as an example: - -### Introduction - -[`src/content/tutorials/learn-angular/intro`](/src/content/tutorials/learn-angular/intro) - -is the introduction of the tutorial, which will live in the `/tutorials/learn-angular` route. - -### Steps - -[`src/content/tutorials/learn-angular/steps`](/src/content/tutorials/learn-angular/steps) is the directory that contains the tutorial steps. - -These are some examples from the `learn-angular` tutorial: - -- [`learn-angular/steps/1-components-in-angular`](/src/content/tutorials/learn-angular/steps/1-components-in-angular): The route will be `/tutorials/learn-angular/components-in-angular` -- [`learn-angular/steps/2-updating-the-component-class`](/src/content/tutorials/learn-angular/steps/2-updating-the-component-class): The route will be `/tutorials/learn-angular/updating-the-component-class` - -Each step directory must start with a number followed by a hyphen, then followed by the step pathname. - -- The number denotes the step, defining which will be the previous and next step within a tutorial. -- The hyphen is a delimiter :). -- The pathname taken from the directory name defines the step URL. - -## Reserved tutorials directories - -### `common` - -The common project is a complete Angular project that is reused by all tutorials. It contains all -dependencies(`package.json`, `package-lock.json`), project configuration(`tsconfig.json`, `angular.json`) and main files to bootstrap the application(`index.html`, `main.ts`, `app.module.ts`). - -A common project is used for a variety of reasons: - -- Avoid duplication of files in tutorials. -- Optimize in-app performance by requesting the common project files and dependencies only once, benefiting from the - browser cache on subsequent requests. -- Require a single `npm install` for all tutorials, therefore reducing the time to interactive with the tutorial - when navigating different tutorials and steps. -- Provide a consistent environment for all tutorials. -- Allow each tutorial to focus on the specific source code for what's being taught and not on the project setup. - -See [`src/content/tutorials/common`](/src/content/tutorials/common) - -### `playground` - -The playground contains the source code for the tutorials playground at `/playground`. It should not contain any content. - -See [`src/content/tutorials/playground`](/src/content/tutorials/playground) - -### `homepage` - -The homepage contains the source code for the homepage playground. It should not contain any content. - -See [`src/content/tutorials/homepage`](/src/content/tutorials/homepage) - -## Update dependencies - -To update the dependencies of all tutorials you can run the following script - -```bash -rm ./adev/src/content/tutorials/homepage/package-lock.json ./adev/src/content/tutorials/first-app/common/package-lock.json ./adev/src/content/tutorials/learn-angular/common/package-lock.json ./adev/src/content/tutorials/playground/common/package-lock.json ./adev/src/content/tutorials/deferrable-views/common/package-lock.json - -npm i --package-lock-only --prefix ./adev/src/content/tutorials/homepage -npm i --package-lock-only --prefix ./adev/src/content/tutorials/first-app/common -npm i --package-lock-only --prefix ./adev/src/content/tutorials/learn-angular/common -npm i --package-lock-only --prefix ./adev/src/content/tutorials/playground/common -npm i --package-lock-only --prefix ./adev/src/content/tutorials/deferrable-views/common -``` diff --git a/glosario.yml b/glosario.yml new file mode 100644 index 00000000..7393b32b --- /dev/null +++ b/glosario.yml @@ -0,0 +1,55 @@ +# Reglas de consistencia terminológica para las traducciones al español. +# +# Inspirado en el prh.yml de angular-ja (https://github.com/angular/angular-ja). +# +# expected: el término correcto +# pattern: las variantes que deben corregirse (regex, sin distinguir mayúsculas) +# reason: por qué, para que el mensaje de error sea educativo +# +# Criterio para agregar reglas: solo términos INEQUÍVOCOS. Una regla que produce +# falsos positivos hace que el linter se ignore por completo, que es peor que no +# tenerlo. Los términos que dependen del contexto (paquete/bundle, +# fragmento/chunk, heredado/legacy) viven en el glosario del skill, no aquí. + +version: 1 + +rules: + - expected: biblioteca + pattern: librería(?!s) + reason: '"librería" significa bookstore; la traducción de "library" es "biblioteca"' + + - expected: bibliotecas + pattern: librerías + reason: '"librerías" significa bookstores; la traducción de "libraries" es "bibliotecas"' + + - expected: signal + pattern: señal(?!es) + reason: 'en Angular "signal" es el nombre de la API; se mantiene en inglés' + + - expected: signals + pattern: señales + reason: 'en Angular "signals" es el nombre de la API; se mantiene en inglés' + + - expected: pristine + pattern: prístino + reason: 'estado de formulario; se mantiene el nombre de la API en inglés' + + - expected: sistema de compilación + pattern: sistema de construcción + reason: '"build" en contexto de CLI se traduce como "compilación"' + + - expected: animaciones en Angular + pattern: animaciones de Angular + reason: 'preferir "en Angular" sobre "de Angular", que suena posesivo' + + - expected: routing en Angular + pattern: routing de Angular + reason: 'preferir "en Angular" sobre "de Angular", que suena posesivo' + + - expected: inyección de dependencias + pattern: inyección de dependencia(?!s) + reason: 'la traducción establecida de "dependency injection" es en plural' + + - expected: Crear schematics + pattern: Autoriza(ndo|r) schematics + reason: '"authoring" es crear/desarrollar, no autorizar' diff --git a/package.json b/package.json index 66ac0e4d..f941403e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "zx tools/build.mjs", "start": "zx tools/watch.mjs", "update-origin": "zx tools/update-origin.mjs", + "check-translations": "zx tools/check-translations.mjs", + "lint-glossary": "zx tools/lint-glossary.mjs", "deploy:staging": "firebase use staging && firebase deploy --only hosting", "deploy:prod": "firebase use production && firebase deploy --only hosting" }, diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs new file mode 100644 index 00000000..8f25a34d --- /dev/null +++ b/tools/check-translations.mjs @@ -0,0 +1,145 @@ +import { $, chalk, argv } from 'zx'; + +/** + * Verifica el estado de sincronización de las traducciones. + * + * Detecta dos problemas distintos que `update-origin` puede dejar atrás: + * + * 1. DESACTUALIZADAS — el archivo ya está traducido (existe `.en.md`) pero el + * inglés cambió después. `update-origin` sobrescribe el `.en.md` con el + * inglés nuevo, así que el `.en.md` tal como estaba en el commit donde se + * tocó por última vez el `.md` es el inglés vigente al traducir. El diff + * contra el `.en.md` actual es exactamente lo que falta traducir. + * + * 2. SIN TRADUCIR — no existe `.en.md`, así que `update-origin` copió el inglés + * directamente al `.md`. Son páginas en inglés publicadas en el sitio + * español (típicamente páginas nuevas de una versión). + * + * No requiere metadata adicional: el historial de git ya contiene todo. + * + * Uso: + * npm run check-translations + * npm run check-translations -- --ref=refs/tmp/pr192 (verificar otra rama) + * npm run check-translations -- --diff (mostrar los diffs) + */ + +$.verbose = false; + +const CONTENT_DIR = 'adev-es/src/content'; + +// Cambios que no afectan la traducción: migración de markup y normalización de +// enlaces que upstream aplica masivamente. +const NOISE_PATTERNS = [/^\s*<\/?docs-code[^>]*>\s*$/, /^\s*```/, /^\s*$/]; +const URL_NOISE = /https:\/\/angular\.dev\//; + +try { + const ref = argv.ref ?? 'HEAD'; + + const all = (await $`git ls-tree -r --name-only ${ref} -- ${CONTENT_DIR}`).stdout + .trim() + .split('\n') + .filter(Boolean); + + const present = new Set(all); + const sources = all.filter((f) => f.endsWith('.md') && !f.endsWith('.en.md')); + + const stale = []; + const untranslated = []; + let synced = 0; + + for (const md of sources) { + const en = md.replace(/\.md$/, '.en.md'); + + if (!present.has(en)) { + untranslated.push(md); + continue; + } + + const lastEs = (await $`git log -1 --format=%H ${ref} -- ${md}`.nothrow()).stdout.trim(); + if (!lastEs) continue; + + const before = (await $`git rev-parse ${lastEs}:${en}`.nothrow()).stdout.trim(); + const now = (await $`git rev-parse ${ref}:${en}`.nothrow()).stdout.trim(); + if (!before || !now) continue; + + if (before === now) { + synced++; + continue; + } + + const diff = (await $`git diff ${lastEs} ${ref} -- ${en}`.nothrow()).stdout; + stale.push({ file: en, since: lastEs.slice(0, 7), ...classify(diff) }); + } + + report({ stale, untranslated, synced, ref }); + + const blocking = stale.filter((s) => s.prose > 0).length + untranslated.length; + process.exit(blocking > 0 ? 1 : 0); +} catch (err) { + console.error(chalk.red(err)); + process.exit(1); +} + +/** + * Separa los cambios de prosa real del ruido de formato, para que el reporte + * sea accionable en vez de un conteo de líneas inflado. Es una heurística + * deliberadamente conservadora: prefiere marcar de más que dejar pasar algo. + */ +function classify(diff) { + const lines = diff.split('\n').filter((l) => /^[+-]/.test(l) && !/^[+-]{3}/.test(l)); + + let prose = 0; + let noise = 0; + + for (const line of lines) { + const content = line.slice(1); + if (NOISE_PATTERNS.some((p) => p.test(content)) || URL_NOISE.test(content)) { + noise++; + } else { + prose++; + } + } + + return { prose, noise, diff }; +} + +function report({ stale, untranslated, synced, ref }) { + const relevant = stale.filter((s) => s.prose > 0); + const cosmetic = stale.filter((s) => s.prose === 0); + const short = (f) => f.replace(`${CONTENT_DIR}/`, ''); + + console.log(chalk.cyan(`\nEstado de traducciones · ${ref}\n`)); + console.log(` ${chalk.green('✔')} Sincronizadas: ${synced}`); + console.log(` ${chalk.yellow('~')} Solo formato: ${cosmetic.length}`); + console.log(` ${chalk.red('✘')} Desactualizadas: ${relevant.length}`); + console.log(` ${chalk.red('✘')} Sin traducir: ${untranslated.length}\n`); + + if (relevant.length) { + console.log(chalk.red.bold('Desactualizadas — el inglés cambió después de traducir:\n')); + for (const s of relevant) { + console.log(` ${chalk.bold(short(s.file))}`); + console.log(chalk.dim(` ${s.prose} líneas de prosa, ${s.noise} de formato · desde ${s.since}`)); + console.log(chalk.dim(` git diff ${s.since} ${ref} -- ${s.file}\n`)); + } + } + + if (untranslated.length) { + console.log(chalk.red.bold('Sin traducir — inglés publicado en el sitio español:\n')); + for (const f of untranslated) console.log(` ${short(f)}`); + console.log(); + } + + if (cosmetic.length) { + console.log(chalk.yellow('Solo cambios de formato en el original (probablemente ignorables):\n')); + for (const s of cosmetic) console.log(chalk.dim(` ${short(s.file)} (${s.noise} líneas)`)); + console.log(); + } + + if (argv.diff && relevant.length) { + console.log(chalk.cyan('─'.repeat(60))); + for (const s of relevant) { + console.log(chalk.bold(`\n${s.file}\n`)); + console.log(s.diff); + } + } +} diff --git a/tools/lint-glossary.mjs b/tools/lint-glossary.mjs new file mode 100644 index 00000000..d83fd16a --- /dev/null +++ b/tools/lint-glossary.mjs @@ -0,0 +1,112 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { $, argv, chalk, glob, YAML } from 'zx'; + +/** + * Verifica la consistencia terminológica de las traducciones al español. + * + * Las reglas viven en `glosario.yml`, con el mismo formato `expected`/`pattern` + * que usa angular-ja en su `prh.yml`. + * + * Igual que el `.textlintrc` de angular-ja, ignora las zonas donde el + * vocabulario español no aplica: bloques de código, código en línea, enlaces, + * rutas de archivo y anchors explícitos `{#id}`. + * + * Uso: + * npm run lint-glossary (todas las traducciones) + * npm run lint-glossary -- guide/forms (solo una ruta) + */ + +$.verbose = false; + +const CONTENT_DIR = 'adev-es/src/content'; +const ROOT = resolve(import.meta.dirname, '..'); + +try { + const raw = await readFile(resolve(ROOT, 'glosario.yml'), 'utf8'); + const { rules } = YAML.parse(raw); + + const filter = argv._[0]; + const all = await glob([`${CONTENT_DIR}/**/*.md`, `!${CONTENT_DIR}/**/*.en.md`], { cwd: ROOT }); + const files = filter ? all.filter((f) => f.includes(filter)) : all; + + const findings = []; + + for (const file of files) { + const text = await readFile(resolve(ROOT, file), 'utf8'); + findings.push(...lintFile(file, text, rules)); + } + + report(findings, files.length); + process.exit(findings.length > 0 ? 1 : 0); +} catch (err) { + console.error(chalk.red(err)); + process.exit(1); +} + +/** + * Reemplaza por espacios las regiones donde no se debe aplicar el glosario, + * conservando las posiciones para que los números de línea sigan siendo exactos. + */ +function mask(text) { + const blank = (m) => m.replace(/[^\n]/g, ' '); + return text + .replace(/```[\s\S]*?```/g, blank) // bloques de código + .replace(//g, blank) // bloques docs-code + .replace(/]*\/>/g, blank) + .replace(/`[^`\n]*`/g, blank) // código en línea + .replace(/\]\([^)\n]*\)/g, blank) // destinos de enlaces + .replace(/\{#[^}\n]*\}/g, blank); // anchors explícitos +} + +function lintFile(file, text, rules) { + const masked = mask(text); + const lines = masked.split('\n'); + const found = []; + + for (const rule of rules) { + const re = new RegExp(rule.pattern, 'giu'); + lines.forEach((line, i) => { + for (const m of line.matchAll(re)) { + found.push({ + file, + line: i + 1, + col: m.index + 1, + found: m[0], + expected: rule.expected, + reason: rule.reason, + }); + } + }); + } + + return found.sort((a, b) => a.line - b.line); +} + +function report(findings, scanned) { + if (findings.length === 0) { + console.log(chalk.green(`\n✔ Sin problemas de terminología en ${scanned} archivos.\n`)); + return; + } + + const byFile = new Map(); + for (const f of findings) { + if (!byFile.has(f.file)) byFile.set(f.file, []); + byFile.get(f.file).push(f); + } + + console.log(chalk.cyan(`\nRevisión de terminología · ${scanned} archivos\n`)); + + for (const [file, items] of byFile) { + console.log(chalk.bold(file.replace(`${CONTENT_DIR}/`, ''))); + for (const f of items) { + console.log( + ` ${chalk.dim(`${f.line}:${f.col}`)} ${chalk.red(f.found)} → ${chalk.green(f.expected)}` + ); + console.log(chalk.dim(` ${f.reason}`)); + } + console.log(); + } + + console.log(chalk.red(`${findings.length} problemas en ${byFile.size} archivos.\n`)); +} diff --git a/tools/update-origin.mjs b/tools/update-origin.mjs index f722d6d8..4d3988cd 100644 --- a/tools/update-origin.mjs +++ b/tools/update-origin.mjs @@ -2,10 +2,23 @@ import { access, copyFile, mkdir } from 'node:fs/promises'; import { extname, resolve, dirname } from 'node:path'; import { $, argv, chalk, glob } from 'zx'; +/** + * Cada entrada es un objetivo de copia independiente: o un patrón suelto, o un + * grupo (patrón + sus exclusiones). Se glob-ea entrada por entrada para poder + * exigir que cada una encuentre al menos un archivo; si un patrón deja de + * coincidir porque upstream renombró o movió algo, falla en vez de omitirlo en + * silencio. + */ const copyTargets = [ // Text contents - 'src/content/**/*.md', - '!src/content/**/license.md', + [ + 'src/content/**/*.md', + '!src/content/**/license.md', + // No se traducen: readmes de apps de ejemplo y páginas índice sin prosa. + '!src/content/examples/**/readme.md', + '!src/content/tutorials/README.md', + '!src/content/reference/concepts/overview.md', + ], // Navigation 'src/app/routing/sub-navigation-data.ts', 'src/app/routing/navigation-entries/index.ts', @@ -45,21 +58,30 @@ async function copyOriginFiles() { const adevOriginDir = 'origin/adev'; const adevEsDir = 'adev-es'; - const files = await glob(copyTargets, { cwd: adevOriginDir }); + for (const target of copyTargets) { + const files = await glob(target, { cwd: adevOriginDir, caseSensitiveMatch: true }); - for (const file of files) { - const src = resolve(adevOriginDir, file); - const ext = extname(file); - const enFilePath = file.replace(`${ext}`, `.en${ext}`); + // Un objetivo que no coincide con nada casi siempre significa que la ruta + // cambió upstream, no que sobre. Fallar aquí evita que el archivo quede + // desactualizado sin que nadie lo note. + if (files.length === 0) { + throw new Error(`No files matched: ${JSON.stringify(target)}`); + } - let isTranslated = false; - try { - await access(resolve(adevEsDir, enFilePath)); - isTranslated = true; - } catch {} - const dest = resolve(adevEsDir, isTranslated ? enFilePath : file); + for (const file of files) { + const src = resolve(adevOriginDir, file); + const ext = extname(file); + const enFilePath = file.replace(`${ext}`, `.en${ext}`); - await mkdir(dirname(dest), { recursive: true }); - await copyFile(src, dest); + let isTranslated = false; + try { + await access(resolve(adevEsDir, enFilePath)); + isTranslated = true; + } catch {} + const dest = resolve(adevEsDir, isTranslated ? enFilePath : file); + + await mkdir(dirname(dest), { recursive: true }); + await copyFile(src, dest); + } } -} \ No newline at end of file +} From fc758b2554d70fe815f5d9bb9ccd72b6f25bc550 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Fri, 14 Aug 2026 00:04:56 -0400 Subject: [PATCH 02/28] feat: turn translation detection into claimable GitHub issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La detección solo servía si alguien se acordaba de correrla y leer la terminal. Este commit la convierte en trabajo visible y reclamable, para poder repartirlo entre varias personas. El modelo es híbrido, según la naturaleza de cada problema: - Sin traducir → un único issue de tracking con checkboxes agrupados por sección. Son muchos y de baja rotación; un issue por cada uno sería ruido. Cada entrada trae un link que pre-rellena un issue de declaración, así solo se abre uno cuando alguien de verdad va a tomar el archivo. - Desactualizados → un issue individual por archivo, con el diff del original incluido. Son pocos y cada uno es una unidad de trabajo concreta, asignable y cerrable. El workflow corre en push a main y también en eventos de issues, para que el tracking refleje los reclamos sin esperar al siguiente push. Detalles que importan: - checkout usa fetch-depth: 0 porque la detección compara el .en.md de commits anteriores. Con el default (depth 1) no encontraría nada. - checkout NO trae el submódulo: el .en.md ya guarda el original, así que no hace falta clonar angular/angular en CI. - El script es idempotente y los diffs se recortan a 12k caracteres, por debajo del límite de GitHub para cuerpos de issue. Verificado con un dry-run sobre los datos reales del repo, simulando el ciclo completo: creación, reejecución sin cambios, declaración de un contribuidor, traducción completada y cierre automático. --- .../ISSUE_TEMPLATE/translation-checkout.md | 39 +++ .github/scripts/sync-translation-issues.mjs | 257 ++++++++++++++++++ .github/workflows/sync-translation-issues.yml | 56 ++++ UPDATE-ORIGIN.md | 9 + tools/check-translations.mjs | 66 ++++- 5 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/translation-checkout.md create mode 100644 .github/scripts/sync-translation-issues.mjs create mode 100644 .github/workflows/sync-translation-issues.yml diff --git a/.github/ISSUE_TEMPLATE/translation-checkout.md b/.github/ISSUE_TEMPLATE/translation-checkout.md new file mode 100644 index 00000000..2fc7e6f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation-checkout.md @@ -0,0 +1,39 @@ +--- +name: Declarar una traducción +about: Avisa que vas a traducir un documento, para que nadie más lo tome +title: 'translate: ' +labels: docs-translation +--- + + + +## Archivo + +`` + +## Antes de empezar + +- [ ] Leí la [guía de contribución](../../CONTRIBUTING.md) +- [ ] Revisé el glosario de términos en [`glosario.yml`](../../glosario.yml) + +## Cómo traducir + +```shell +# 1. Respalda el original en inglés (esto es lo que protege tu traducción +# de ser sobrescrita en la próxima sincronización) +cp adev-es/src/content/.md adev-es/src/content/.en.md + +# 2. Traduce el archivo .md + +# 3. Verifica antes de abrir el PR +npm run lint-glossary +npm run check-translations +``` + +Al mergearse el PR, este issue y el de tracking se actualizan solos. diff --git a/.github/scripts/sync-translation-issues.mjs b/.github/scripts/sync-translation-issues.mjs new file mode 100644 index 00000000..6f4ad8ac --- /dev/null +++ b/.github/scripts/sync-translation-issues.mjs @@ -0,0 +1,257 @@ +/** + * Convierte la salida de `check-translations --json` en issues de GitHub. + * + * Usa un modelo híbrido, según la naturaleza de cada problema: + * + * - SIN TRADUCIR → un único issue de tracking con checkboxes. Son muchos y de + * baja rotación; abrir un issue por cada uno sería ruido. Cada entrada trae un + * link que pre-rellena un issue de "declaración" para que alguien lo reclame + * solo cuando de verdad vaya a trabajarlo. + * + * - DESACTUALIZADOS → un issue individual por archivo. Son pocos y cada uno + * viene con un diff concreto y accionable, así que se pueden asignar y cerrar + * como unidades de trabajo reales. + * + * Es idempotente: se puede correr en cada push a main sin duplicar nada. + */ + +const TRACKING_TITLE = 'Tracking: documentos sin traducir'; +const TRACKING_LABELS = ['docs-translation', 'help wanted']; +const STALE_LABELS = ['docs-translation']; +const STALE_PREFIX = 'sync:'; + +const CATEGORY_NAMES = { + guide: '📖 Guías', + tutorials: '🎓 Tutoriales', + reference: '📚 Referencia', + 'best-practices': '⚡ Mejores prácticas', + tools: '🛠️ Herramientas', + ecosystem: '🌐 Ecosistema', + ai: '🤖 IA', + cli: '🔧 CLI', + examples: '📦 Ejemplos', + other: '📄 Otros', +}; + +const CATEGORY_ORDER = Object.keys(CATEGORY_NAMES); + +// GitHub rechaza cuerpos de issue por encima de ~65k caracteres. +const MAX_DIFF_CHARS = 12000; + +export default async function syncIssues({ github, context, core, data }) { + const { owner, repo } = context.repo; + + const existing = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'all', + labels: 'docs-translation', + per_page: 100, + }); + + await syncTracking({ github, core, owner, repo, existing, data }); + await syncStale({ github, core, owner, repo, existing, data }); +} + +/* ------------------------------------------------------------------ */ +/* Sin traducir: un solo issue de tracking */ +/* ------------------------------------------------------------------ */ + +async function syncTracking({ github, core, owner, repo, existing, data }) { + const { untranslated } = data; + + // Los issues de declaración abiertos por contribuidores marcan qué archivos + // ya están reclamados, para no pedir voluntarios dos veces. + const claimed = new Map(); + for (const issue of existing) { + const m = issue.title.match(/^translate:\s*(\S+)/); + if (m && issue.state === 'open') claimed.set(normalize(m[1]), issue.number); + } + + const body = buildTrackingBody({ untranslated, claimed, owner, repo }); + const tracking = existing.find((i) => i.title === TRACKING_TITLE); + + if (!tracking) { + if (untranslated.length === 0) { + core.info('No hay archivos sin traducir; no se crea el issue de tracking.'); + return; + } + const { data: created } = await github.rest.issues.create({ + owner, + repo, + title: TRACKING_TITLE, + body, + labels: TRACKING_LABELS, + }); + core.info(`Issue de tracking creado: #${created.number}`); + return; + } + + if (tracking.body === body && tracking.state === 'open') { + core.info('Issue de tracking sin cambios.'); + return; + } + + await github.rest.issues.update({ + owner, + repo, + issue_number: tracking.number, + body, + state: 'open', + }); + core.info(`Issue de tracking actualizado: #${tracking.number}`); +} + +function buildTrackingBody({ untranslated, claimed, owner, repo }) { + if (untranslated.length === 0) { + return [ + '## 🎉 No hay documentos sin traducir', + '', + 'Todas las páginas del sitio tienen traducción al español. ¡Gracias!', + '', + 'Este issue se actualiza automáticamente cuando `update-origin` trae páginas nuevas.', + ].join('\n'); + } + + const groups = new Map(); + for (const f of untranslated) { + if (!groups.has(f.category)) groups.set(f.category, []); + groups.get(f.category).push(f); + } + + const lines = [ + '## 📋 Documentos sin traducir', + '', + 'Este issue se actualiza solo. Para tomar un archivo, usa su link **📝 Declarar**:', + 'eso abre un issue a tu nombre y marca la casilla aquí.', + '', + `**Pendientes:** ${untranslated.length}`, + '', + ]; + + for (const category of CATEGORY_ORDER) { + const files = groups.get(category); + if (!files?.length) continue; + + lines.push(`### ${CATEGORY_NAMES[category]}`, ''); + for (const f of files.sort((a, b) => a.path.localeCompare(b.path))) { + const gh = `https://github.com/${owner}/${repo}/blob/main/${f.file}`; + const claim = claimed.get(normalize(f.path)); + if (claim) { + lines.push(`- [x] \`${f.path}\` ([GitHub](${gh}) · #${claim})`); + } else { + const url = + `https://github.com/${owner}/${repo}/issues/new` + + `?template=translation-checkout.md` + + `&labels=docs-translation&title=${encodeURIComponent(`translate: ${f.path}`)}`; + lines.push(`- [ ] \`${f.path}\` ([GitHub](${gh}) · [📝 Declarar](${url}))`); + } + } + lines.push(''); + } + + lines.push('---', '', 'Generado por `npm run check-translations`.'); + return lines.join('\n'); +} + +/* ------------------------------------------------------------------ */ +/* Desactualizados: un issue por archivo */ +/* ------------------------------------------------------------------ */ + +async function syncStale({ github, core, owner, repo, existing, data }) { + const { stale } = data; + + const openStale = new Map(); + for (const issue of existing) { + if (issue.state !== 'open') continue; + const m = issue.title.match(/^sync:\s*(\S+)/); + if (m) openStale.set(normalize(m[1]), issue); + } + + for (const file of stale) { + const key = normalize(file.path); + const body = buildStaleBody({ file, owner, repo }); + const found = openStale.get(key); + + if (!found) { + const { data: created } = await github.rest.issues.create({ + owner, + repo, + title: `${STALE_PREFIX} ${file.path}`, + body, + labels: STALE_LABELS, + }); + core.info(`Issue creado: #${created.number} (${file.path})`); + } else if (found.body !== body) { + // El inglés volvió a cambiar desde que se abrió: refrescar el diff. + await github.rest.issues.update({ + owner, + repo, + issue_number: found.number, + body, + }); + core.info(`Issue actualizado: #${found.number} (${file.path})`); + } + + openStale.delete(key); + } + + // Lo que quedó abierto ya no aparece en la detección: se tradujo. + for (const [path, issue] of openStale) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: 'La traducción ya está al día con el original. Cerrando automáticamente.', + }); + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'completed', + }); + core.info(`Issue cerrado: #${issue.number} (${path})`); + } +} + +function buildStaleBody({ file, owner, repo }) { + const gh = `https://github.com/${owner}/${repo}/blob/main/${file.file}`; + const en = file.file.replace(/\.md$/, '.en.md'); + + let diff = file.diff; + let truncated = false; + if (diff.length > MAX_DIFF_CHARS) { + diff = diff.slice(0, MAX_DIFF_CHARS); + truncated = true; + } + + const lines = [ + `El original en inglés cambió después de que se tradujo \`${file.path}\`.`, + '', + `- **Archivo a actualizar:** [\`${file.path}\`](${gh})`, + `- **Traducido por última vez en:** \`${file.since}\``, + `- **Cambios en el original:** ${file.prose} líneas de prosa` + + (file.noise ? `, ${file.noise} de formato (ignorables)` : ''), + '', + '## Qué cambió en el original', + '', + 'Solo hace falta aplicar estos cambios al español; el resto del archivo ya está bien.', + '', + '```diff', + diff.trimEnd(), + '```', + ]; + + if (truncated) { + lines.push('', '_Diff recortado. Para verlo completo:_', '', '```shell', `git diff ${file.since} HEAD -- ${en}`, '```'); + } + + lines.push('', '---', '', 'Detectado por `npm run check-translations`. Se cierra solo al actualizarse la traducción.'); + return lines.join('\n'); +} + +/** Los títulos los escriben humanos: normalizar antes de comparar rutas. */ +function normalize(path) { + return path.trim().replace(/^`|`$/g, '').replace(/^adev-es\/src\/content\//, ''); +} diff --git a/.github/workflows/sync-translation-issues.yml b/.github/workflows/sync-translation-issues.yml new file mode 100644 index 00000000..626a3353 --- /dev/null +++ b/.github/workflows/sync-translation-issues.yml @@ -0,0 +1,56 @@ +name: Sync Translation Issues + +on: + push: + branches: + - main + # Cuando alguien declara o cierra una traducción, el tracking issue debe + # reflejarlo sin esperar al siguiente push. + issues: + types: [opened, closed, reopened, labeled] + workflow_dispatch: + +permissions: + contents: read + issues: write + +# Dos ejecuciones simultáneas pelearían por los mismos issues. +concurrency: + group: sync-translation-issues + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # La detección de traducciones desactualizadas compara el .en.md de + # commits anteriores, así que necesita el historial completo. + fetch-depth: 0 + # No hace falta el submódulo: el .en.md ya guarda el original. + submodules: false + + - name: Setup Node JS + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.node-version' + + - name: Install Dependencies + run: npm ci + + - name: Detect translation status + id: detect + run: | + npx zx tools/check-translations.mjs --json > /tmp/status.json || true + echo "count=$(node -p "require('/tmp/status.json').stale.length + require('/tmp/status.json').untranslated.length")" >> "$GITHUB_OUTPUT" + + - name: Sync issues + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const { readFileSync } = require('node:fs'); + const { default: syncIssues } = await import('${{ github.workspace }}/.github/scripts/sync-translation-issues.mjs'); + const data = JSON.parse(readFileSync('/tmp/status.json', 'utf8')); + await syncIssues({ github, context, core, data }); diff --git a/UPDATE-ORIGIN.md b/UPDATE-ORIGIN.md index f2156e4b..c6edeabf 100644 --- a/UPDATE-ORIGIN.md +++ b/UPDATE-ORIGIN.md @@ -66,6 +66,15 @@ git fetch origin pull//head:refs/tmp/pr npm run check-translations -- --ref=refs/tmp/pr ``` +### Issues automáticos + +En cada push a `main`, el workflow `sync-translation-issues` convierte el resultado de la detección en trabajo reclamable, con dos formatos según el tipo de problema: + +- **Sin traducir** → un único issue de tracking con checkboxes, agrupado por sección. Son muchos y de baja rotación, así que abrir un issue por cada uno sería ruido. Cada entrada trae un link **📝 Declarar** que pre-rellena un issue a tu nombre; al crearlo, la casilla se marca sola. +- **Desactualizados** → un issue individual por archivo, con el diff del original incluido. Son pocos y cada uno es una unidad de trabajo concreta y asignable. Se cierran solos cuando la traducción se pone al día. + +El workflow también corre cuando se abre o cierra un issue, así que el tracking refleja los reclamos sin esperar al siguiente push. + ### `lint-glossary` Verifica que se usen los términos acordados. Las reglas viven en [`glosario.yml`](./glosario.yml) con formato `expected` / `pattern`. Ignora bloques de código, código en línea, enlaces y anchors `{#id}`, donde el vocabulario español no aplica. diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs index 8f25a34d..f490444b 100644 --- a/tools/check-translations.mjs +++ b/tools/check-translations.mjs @@ -21,12 +21,31 @@ import { $, chalk, argv } from 'zx'; * npm run check-translations * npm run check-translations -- --ref=refs/tmp/pr192 (verificar otra rama) * npm run check-translations -- --diff (mostrar los diffs) + * npm run check-translations -- --json (salida para automatización) */ $.verbose = false; const CONTENT_DIR = 'adev-es/src/content'; +/** Agrupa los archivos por sección para que los reportes sean navegables. */ +function categorize(path) { + const p = path.replace(`${CONTENT_DIR}/`, ''); + const [top] = p.split('/'); + const known = [ + 'guide', + 'tutorials', + 'reference', + 'best-practices', + 'tools', + 'ecosystem', + 'ai', + 'cli', + 'examples', + ]; + return known.includes(top) ? top : 'other'; +} + // Cambios que no afectan la traducción: migración de markup y normalización de // enlaces que upstream aplica masivamente. const NOISE_PATTERNS = [/^\s*<\/?docs-code[^>]*>\s*$/, /^\s*```/, /^\s*$/]; @@ -68,10 +87,20 @@ try { } const diff = (await $`git diff ${lastEs} ${ref} -- ${en}`.nothrow()).stdout; - stale.push({ file: en, since: lastEs.slice(0, 7), ...classify(diff) }); + stale.push({ + file: en, + source: md, + category: categorize(md), + since: lastEs.slice(0, 7), + ...classify(diff), + }); } - report({ stale, untranslated, synced, ref }); + if (argv.json) { + reportJson({ stale, untranslated, synced }); + } else { + report({ stale, untranslated, synced, ref }); + } const blocking = stale.filter((s) => s.prose > 0).length + untranslated.length; process.exit(blocking > 0 ? 1 : 0); @@ -103,6 +132,39 @@ function classify(diff) { return { prose, noise, diff }; } +/** + * Salida legible por máquina, para el workflow que sincroniza los issues. + * Solo incluye los desactualizados con cambios de prosa: los que solo cambiaron + * de formato no ameritan abrirle un issue a nadie. + */ +function reportJson({ stale, untranslated, synced }) { + console.log( + JSON.stringify( + { + synced, + stale: stale + .filter((s) => s.prose > 0) + .map(({ source, category, since, prose, noise, diff }) => ({ + path: source.replace(`${CONTENT_DIR}/`, ''), + file: source, + category, + since, + prose, + noise, + diff, + })), + untranslated: untranslated.map((f) => ({ + path: f.replace(`${CONTENT_DIR}/`, ''), + file: f, + category: categorize(f), + })), + }, + null, + 2 + ) + ); +} + function report({ stale, untranslated, synced, ref }) { const relevant = stale.filter((s) => s.prose > 0); const cosmetic = stale.filter((s) => s.prose === 0); From bd5e98479c2afe7ed7397f00ffdc5a45fd4fa461 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 19:52:55 -0400 Subject: [PATCH 03/28] fix: stop translating alert prefixes, which breaks 423 callouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los prefijos de alerta son claves del tokenizer de adev, no prosa. adev/shared-docs/pipeline/shared/marked/extensions/docs-alert.mts declara un enum con las claves en INGLES y construye el matcher desde Object.keys(AlertSeverityLevel). Verificado contra angular/angular al SHA que tenemos fijado (47a7396) y probado con el regex reconstruido: NOTE: hola -> matchea, renderiza la caja NOTA: hola -> NO matchea IMPORTANTE: hola -> NO matchea ÚTIL: hola -> NO matchea Nuestro corpus tiene 423 avisos con el prefijo traducido, que hoy se renderizan como párrafo plano en vez de caja de color: ÚTIL 137, NOTA 109, IMPORTANTE 81, CONSEJO 74, RESUMEN 16, CRÍTICO 5, PREGUNTA 1. La causa es nuestro propio skill, que instruía traducirlos. angular-ja, con 10 años en esto, mantiene la clave en inglés y traduce solo el cuerpo: HELPFUL: これは、一般的なランタイムエラー... Cambios: - El skill ahora manda dejar el prefijo en inglés, con la evidencia y el ejemplo de angular-ja. - 7 reglas nuevas en glosario.yml que detectan los prefijos traducidos. El linter marca 410; los 13 restantes viven dentro de bloques de código, donde el enmascarado los ignora correctamente. Además, el Paso 1 del skill era destructivo. Hacía `cp archivo.md archivo.en.md` sin condición: aplicado a un archivo YA traducido pero desactualizado, escribe español sobre el .en.md y destruye el único registro de qué inglés se tradujo, dejando ese archivo indetectable para check-translations para siempre. Ahora exige comprobar primero si el .en.md existe. Esto NO arregla los 423 callouts existentes: evita que sigan apareciendo. El arreglo del corpus va después de que mergee #192, para no colisionar con sus 685 archivos. También se versiona .claude/skills/ (antes solo vivía en un portátil) y se ignora settings.local.json, que tiene rutas absolutas personales. --- .claude/skills/batch-translate.md | 121 +++++++ .claude/skills/translate-angular-docs.md | 426 +++++++++++++++++++++++ .gitignore | 3 + glosario.yml | 31 ++ 4 files changed, 581 insertions(+) create mode 100644 .claude/skills/batch-translate.md create mode 100644 .claude/skills/translate-angular-docs.md diff --git a/.claude/skills/batch-translate.md b/.claude/skills/batch-translate.md new file mode 100644 index 00000000..49f4d000 --- /dev/null +++ b/.claude/skills/batch-translate.md @@ -0,0 +1,121 @@ +--- +name: batch-translate +description: Traducir múltiples archivos de documentación Angular en lote +--- + +# Batch Translation Agent + +Traduce múltiples archivos de documentación Angular del inglés al español de forma secuencial. Para cada archivo aplica el mismo proceso que `/translate-angular-docs`. + +## Cuándo usar este agent + +- Tienes una lista de archivos relacionados (ej. todos los guides de forms) +- Quieres procesar una carpeta completa o sección +- Necesitas un reporte de qué se tradujo y qué quedó pendiente + +## Uso + +Pasa una lista de archivos o describe la sección a traducir: + +``` +/batch-translate guide/forms/overview.md guide/forms/reactive-forms.md guide/forms/validation.md +``` + +O con una descripción: +``` +/batch-translate todos los archivos sin traducir en reference/configs/ +``` + +--- + +## Proceso para cada archivo + +### 1. Verificar estado + +Antes de traducir, comprueba si el archivo ya está traducido: +- Si existe `archivo.en.md` → el `archivo.md` ya fue traducido (saltar o confirmar con el usuario) +- Si no existe `archivo.en.md` → el `archivo.md` está en inglés, proceder + +### 2. Crear backup + +```bash +cp adev-es/src/content//archivo.md adev-es/src/content//archivo.en.md +``` + +### 3. Leer el archivo original + +Lee el contenido completo antes de traducir. + +### 4. Traducir + +Aplica todas las reglas del skill `/translate-angular-docs`: +- Respeta el glosario de términos +- Mantén el código intacto (traduce solo los comentarios) +- Preserva el formato markdown +- Mantén alineación de líneas cuando sea posible +- Traduce las etiquetas `` correctamente + +### 5. Escribir la traducción + +Sobreescribe `archivo.md` con la traducción. + +### 6. Verificar anchors + +Si se tradujeron encabezados con enlaces internos, actualiza los anchors. + +### 7. Stage en git + +```bash +git add adev-es/src/content//archivo.md adev-es/src/content//archivo.en.md +``` + +--- + +## Reglas del batch + +- **Procesar secuencialmente**, un archivo a la vez (no en paralelo) +- **Confirmar antes de empezar** si la lista tiene más de 5 archivos +- **No mezclar archivos** de carpetas muy distintas en un mismo commit +- **Pausar si hay duda** sobre algún término técnico no listado en el glosario — preguntar al usuario + +--- + +## Reporte final + +Al terminar, entrega un resumen con este formato: + +``` +## Resumen de traducción + +✅ Traducidos (N archivos): + - guide/forms/overview.md + - guide/forms/reactive-forms.md + +⏭️ Omitidos (ya tenían .en.md): + - guide/forms/validation.md + +❌ Con problemas: + - guide/forms/template-driven.md → razón + +## Próximos pasos + +git commit -m "translate: translations for forms guides" +``` + +--- + +## Commit al finalizar el lote + +Agrupa los archivos de la misma sección en un solo commit: + +```bash +# Formato: +git commit -m "translate: translations for " + +# Ejemplos: +# translate: translations for forms guides +# translate: translations for reference configs section +# translate: complete translation of routing guides +``` + +Si los archivos son de secciones distintas, haz commits separados por sección. diff --git a/.claude/skills/translate-angular-docs.md b/.claude/skills/translate-angular-docs.md new file mode 100644 index 00000000..a566221b --- /dev/null +++ b/.claude/skills/translate-angular-docs.md @@ -0,0 +1,426 @@ +--- +name: translate-angular-docs +description: Traducir Documentación Angular (Inglés → Español) +--- + +# Traducir Documentación Angular (Inglés → Español) + +Eres un traductor técnico especializado en documentación de Angular. Traducir documentación de inglés a español manteniendo precisión técnica, consistencia terminológica y naturalidad en el español. + +## Flujo de Trabajo + +### Paso 1 — Backup del original + +**Primero comprueba si el archivo ya está traducido.** El comando depende de eso, y +equivocarse destruye datos de forma irreversible: + +```shell +ls adev-es/src/content//archivo.en.md +``` + +**Si NO existe** → el `.md` está en inglés. Crea el respaldo y luego traduce: + +```shell +cp adev-es/src/content//archivo.md adev-es/src/content//archivo.en.md +``` + +**Si YA existe** → el `.md` ya está en español y solo hay que actualizarlo. **NO copies +nada.** Ese `cp` escribiría español sobre el `.en.md`, que es el único registro de qué +inglés se tradujo. Sin él, `check-translations` deja de poder detectar cambios en ese +archivo **para siempre**. Trabaja directo sobre el `.md` y deja el `.en.md` intacto: +`update-origin` ya lo actualizó con el inglés nuevo. + +- `archivo.en.md` → Original en inglés (respaldo — lo mantiene `update-origin`) +- `archivo.md` → Traducción al español (editar este) + +> [!IMPORTANT] +> Toda traducción necesita su `.en.md`, incluso las parciales. Sin él, el próximo +> `update-origin` sobrescribe tu traducción con el inglés y el trabajo se pierde sin +> aviso (`tools/update-origin.mjs:76-81`). + +### Paso 2 — Leer el archivo completo + +Lee todo el contenido antes de empezar. Identifica: + +- Bloques de código (NO traducir el código, SÍ los comentarios) +- Etiquetas especiales `` +- Encabezados con anchors que tengan enlaces internos + +### Paso 3 — Traducir + +Traduce párrafo por párrafo, no palabra por palabra. Aplica las reglas de vocabulario de este documento. + +**Alineación de líneas:** Intenta mantener el mismo número de líneas entre el original y la traducción para facilitar diffs futuros. + +### Paso 4 — Verificar anchors y navegación + +Si tradujiste encabezados `###` que tienen enlaces internos `[texto](#anchor)`, actualiza los anchors en los enlaces. + +Si el archivo afecta la navegación del sitio, revisa: + +``` +adev-es/src/app/routing/sub-navigation-data.ts +``` + +### Paso 5 — Checklist de calidad + +Ejecuta el checklist al final de este documento antes de entregar. + +--- + +## Reglas de Vocabulario + +### 1. Términos que NO se traducen (siempre en inglés) + +`standalone` · `bootstrap` · `DOM` · `shadow DOM` · `shadow tree` · `shadow root` · `framework` · `tree-shaking` · `API` · `drag and drop` · `drop list` · `callback` · `placeholder` · `scaffold` / `scaffolding` · `toolchain` · `CDK` · `lazy loading` · `eager loading` · `keyframes` · `easing` · `DevOps` · `mock` · `stub` · `spy` · `fixture` · `test bed` · `harness` · `polyfill` · `shim` · `middleware` · `pipeline` · `endpoint` · `trigger` _(contexto de animaciones)_ · `host bindings` + +### 2. Términos Híbridos + +En texto narrativo usa la traducción española. En código, nombres de API y decoradores mantén el inglés. + +| Término | Narrativo (ES) | Código/API (EN) | +| --------------- | -------------------------------------------- | ----------------------------------- | +| component | componente | `@Component`, `component` | +| directive | directiva | `@Directive`, `directive` | +| template | plantilla | `template:` | +| decorator | decorador | `@Component()`, `@Input()` | +| input | entrada(s) | `input()`, `inputs:`, `@Input()` | +| output | salida(s) | `output()`, `outputs:`, `@Output()` | +| provider | proveedor(es) | `providers:`, `provider` | +| style | estilo(s) | `styles:`, `style` | +| animation | animación(es) | `animations:`, `animation` | +| trigger | disparador (general) / trigger (animaciones) | `trigger()` | +| state | estado(s) | `state()`, `state:` | +| transition | transición(es) | `transition()` | +| form | formulario(s) | `form`, `FormGroup`, `FormControl` | +| control | control(es) | `FormControl`, `control` | +| validator | validador(es) | `Validators`, `validator` | +| pipe | pipe(s) | `@Pipe`, `pipe` | +| module | módulo(s) | `@NgModule`, `module` | +| route / routing | ruta(s) / enrutamiento | `Route`, `Router`, `routing` | +| signal | signal(s) _(preferir sobre "señal")_ | `signal()` | +| computed | computed _(preferir sobre "calculado")_ | `computed()` | +| guard | guard _(preferir sobre "guardia")_ | `CanActivate`, etc. | + +### 3. Traducciones Consistentes + +| Inglés | Español | +| -------------------------- | ----------------------------------------------------- | +| host element | elemento host | +| binding | enlace (narrativo) | +| property binding | enlace de propiedad | +| attribute binding | enlace de atributo | +| event binding | enlace de evento | +| two-way binding | enlace bidireccional | +| lifecycle hooks | hooks de ciclo de vida | +| dependency injection | inyección de dependencias | +| inject (verbo) | inyectar | +| injector | inyector | +| service | servicio | +| render (verbo) | renderizar | +| rendering | renderización | +| compile | compilar | +| compilation | compilación | +| runtime | tiempo de ejecución | +| build time | tiempo de compilación | +| build (CLI) | compilar (en contexto de `ng build`) | +| encapsulation | encapsulación | +| view encapsulation | encapsulación de vista | +| event listener | escuchador de eventos | +| event handler | manejador de eventos | +| query / queries | consulta(s) | +| effect | efecto(s) | +| subscription | suscripción | +| subscribe | suscribirse | +| emit | emitir | +| observable | observable | +| library | biblioteca (**NO** "librería" — librería = bookstore) | +| environment | entorno | +| deployment | despliegue | +| hydration | hidratación | +| workspace | espacio de trabajo | +| overview | visión general | +| getting started | primeros pasos | +| best practices | mejores prácticas | +| accessibility | accesibilidad | +| security | seguridad | +| migration | migración | +| deprecated | deprecado/a | +| legacy | legacy _(preferir sobre "heredado")_ | +| bundle | bundle _(preferir sobre "paquete")_ | +| chunk | chunk _(preferir sobre "fragmento")_ | +| resolver | resolver | +| interceptor | interceptor | +| schematic | schematic | +| performance | rendimiento | +| cache | caché | +| payload | payload | +| request | petición / solicitud | +| response | respuesta | +| reusable | reutilizable | +| boilerplate | código repetitivo / boilerplate | +| breaking change | cambio disruptivo | +| feature | característica / funcionalidad | +| authoring | crear / desarrollar (**NO** "autorizar") | +| profiling | perfilado | +| interop / interoperability | interoperabilidad | +| selector | selector | +| metadata | metadatos | +| consumer | consumidor | +| instance | instancia | +| attribute directive | directiva de atributo | +| host directive | directiva host | +| structural directive | directiva estructural | +| alias | alias | +| view query | consulta de vista | +| content query | consulta de contenido | +| observer | observador | +| broadcast (verbo) | difundir / transmitir | +| reactive | reactivo/a | +| immutable | inmutable | +| mutable | mutable | +| token | token | +| injection token | token de inyección | +| wrapper | envoltorio / wrapper _(según contexto)_ | +| helper | helper _(preferir sobre "ayudante" en código)_ | +| utility | utilidad | +| entry component | componente de entrada | +| preload | precargar | +| asset | recurso / asset _(según contexto)_ | +| project | proyecto | +| builder | builder / constructor _(según contexto)_ | +| architect | architect | +| fallback | alternativa / fallback _(según contexto)_ | +| enhancement | mejora | +| bugfix | corrección de error / bugfix | +| workaround | solución alternativa / workaround | +| benchmark | benchmark / punto de referencia | +| session | sesión | +| storage | almacenamiento | +| cookie | cookie | +| header | encabezado / header _(según contexto)_ | +| composable | componible | +| draggable | arrastrable | +| droppable | soltable | +| sortable | ordenable | +| resizable | redimensionable | +| end-to-end (E2E) | de extremo a extremo | +| style guide | guía de estilo | +| validation | validación | +| custom controls | controles personalizados | +| field state | estado de campo | + +### 4. Frases y Verbos Comunes + +| Inglés | Español | +| -------------------- | -------------------------------------- | +| Learn more about | Aprende más sobre | +| Note that... | Ten en cuenta que... | +| Keep in mind that... | Ten en cuenta que... / Recuerda que... | +| Make sure to... | Asegúrate de... | +| Under the hood | Internamente / Bajo el capó | +| Out of the box | De forma predeterminada | +| Before you begin | Antes de comenzar | +| As shown above | Como se mostró anteriormente | +| configure | configurar | +| set up | configurar / establecer | +| invoke | invocar | +| trigger (general) | disparar / activar | +| fire (event) | disparar / lanzar | +| handle | manejar / gestionar | +| parse | parsear | +| fetch | obtener / recuperar | +| override | sobrescribir / anular | +| bind | vincular / enlazar | +| dispatch | despachar / enviar | +| validate | validar | +| sanitize | sanear / sanitizar | +| refactor | refactorizar | +| optimize | optimizar | +| debounce | debounce / anti-rebote | +| throttle | throttle / limitar frecuencia | +| import | importar | +| export | exportar | +| define | definir | +| declare | declarar | +| initialize | inicializar | +| instantiate | instanciar | +| call | llamar | +| process | procesar | +| retrieve | recuperar / obtener | +| update | actualizar | +| modify | modificar | +| extend | extender | +| implement | implementar | +| provide | proporcionar / proveer | +| attach | adjuntar / anexar | +| detach | desconectar / desvincular | +| unsubscribe | cancelar suscripción / desuscribirse | +| observe | observar | +| watch | observar / vigilar | +| listen (to) | escuchar | +| navigate | navegar | +| redirect | redirigir | +| resolve | resolver | +| reject | rechazar | +| transform | transformar | +| map | mapear | +| filter | filtrar | +| reduce | reducir | +| merge | fusionar / combinar | +| split | dividir / separar | +| combine | combinar | +| compose | componer | + +### 5. Adjetivos y Estados Técnicos + +| Inglés | Español | +| --------------------- | ---------------------------- | +| optional | opcional | +| required | requerido / obligatorio | +| default | predeterminado / por defecto | +| custom | personalizado | +| built-in | integrado / incorporado | +| external | externo | +| internal | interno | +| public | público | +| private | privado | +| protected | protegido | +| static | estático | +| dynamic | dinámico | +| asynchronous / async | asíncrono / async | +| synchronous | síncrono | +| imperative | imperativo | +| declarative | declarativo | +| enabled | habilitado / activado | +| disabled | deshabilitado / desactivado | +| available | disponible | +| experimental | experimental | +| stable | estable | +| unstable | inestable | +| pending | pendiente | +| resolved | resuelto | +| rejected | rechazado | +| active | activo | +| inactive | inactivo | + +--- + +## Casos Especiales + +### Formularios (Forms) + +- `touched` → touched _(preferir sobre "tocado")_ +- `pristine` → pristine _(preferir sobre "prístino")_ +- `dirty` → dirty _(preferir sobre "modificado")_ +- `valid` / `invalid` → válido / inválido + +### Signals + +- `signal` → signal _(puede usar "señal" entre paréntesis la primera vez)_ +- `computed` → computed _(no traducir)_ +- `effect` → efecto +- `writable signal` → signal editable +- `read-only signal` → signal de solo lectura + +### Etiquetas especiales de Angular docs + +| Etiqueta | Qué hacer | +| -------------------------- | -------------------------------------------------- | +| `` | Traducir contenido interno | +| `` | Traducir atributo `header` si es texto descriptivo | +| `` | Traducir atributo `title` | +| `NOTE:` `TIP:` `IMPORTANT:` `HELPFUL:` `CRITICAL:` `SUMMARY:` `QUESTION:` `TODO:` `TL;DR:` | **NO traducir el prefijo.** Traducir solo el texto que sigue. | + +> [!WARNING] +> **Los prefijos de alerta son claves del tokenizer, no prosa.** +> +> `adev/shared-docs/pipeline/shared/marked/extensions/docs-alert.mts` solo reconoce +> las claves en inglés. `NOTA:`, `CONSEJO:`, `ÚTIL:` e `IMPORTANTE:` **no matchean**, +> así que el aviso se renderiza como párrafo plano en vez de caja de color. +> +> Hay 423 callouts ya rotos en el corpus por esta causa. angular-ja, con 10 años de +> experiencia, mantiene la clave en inglés y traduce solo el cuerpo: +> +> ```markdown +> HELPFUL: これは、一般的なランタイムエラー... +> ``` +> +> Haz lo mismo en español: +> +> ```markdown +> HELPFUL: Este es el equivalente del compilador para el error... +> ``` + +### Anchors de encabezados + +Al traducir un encabezado, el anchor cambia automáticamente. Actualiza los enlaces internos: + +```markdown + + +### Trusting safe values + +[ver sección](#trusting-safe-values) + + + +### Confiar en valores seguros + +[ver sección](#confiar-en-valores-seguros) +``` + +### Títulos — Patrones comunes + +- "Introduction to X" → "Introducción a X" +- "Getting started with X" → "Primeros pasos con X" +- "Understanding X" → "Entendiendo X" / "Comprendiendo X" +- "Working with X" → "Trabajando con X" +- "Advanced X" → "X avanzado/a" +- "X in Angular" → "X en Angular" (**NO** "X de Angular") +- "Building X" → "Construyendo X" / "Creando X" + +### Preposición con Angular + +- Usar **"en Angular"** → "animaciones en Angular", "routing en Angular" +- Evitar **"de Angular"** (suena posesivo) + +--- + +## Errores Comunes a Evitar + +1. **NO** traducir funciones/APIs: `input()` → ~~`entrada()`~~ +2. **NO** traducir props de configuración: `providers:` → ~~`proveedores:`~~ +3. **NO** traducir dentro de backticks (código): `` `@Component` `` → ~~`` `@Componente` ``~~ +4. **NO** usar "de Angular" para contextos: ~~"animaciones de Angular"~~ → "animaciones en Angular" +5. **NO** dejar sin traducir términos del glosario: `library` → ~~`library`~~ → "biblioteca" +6. **NO** usar "librería" para "library": ~~"librerías"~~ → "bibliotecas" +7. **NO** traducir nombres de archivos, rutas, URLs +8. **NO** omitir comentarios en código (sí se traducen) +9. **NO** olvidar actualizar anchors al traducir encabezados +10. **NO** mezclar inconsistentemente: si usas "signal", no cambies a "señal" +11. **NO** usar "construcción" para `build` en CLI: ~~"sistema de construcción"~~ → "sistema de compilación" +12. **NO** confundir "authoring" con "autorizar": "Authoring schematics" → "Crear schematics" + +--- + +## Checklist de Control de Calidad + +Antes de finalizar, verifica: + +- [ ] **Backup creado:** `archivo.en.md` existe +- [ ] **Código intacto:** ningún bloque de código fue traducido (excepto comentarios) +- [ ] **APIs en inglés:** decoradores, funciones y nombres de API permanecen en inglés +- [ ] **Glosario aplicado:** términos del glosario tienen las traducciones correctas +- [ ] **"NO traducir" respetados:** `standalone`, `bootstrap`, `lazy loading`, etc. en inglés +- [ ] **Markdown intacto:** encabezados, listas, tablas, enlaces, énfasis preservados +- [ ] **Etiquetas ``:** contenido interno traducido, estructura preservada +- [ ] **Archivos y rutas:** sin traducir +- [ ] **Versiones:** en formato original ("Angular 17", no "Angular diecisiete") +- [ ] **Anchors actualizados:** enlaces internos apuntan a los anchors traducidos +- [ ] **Comentarios en código:** traducidos +- [ ] **Naturalidad:** el texto español suena natural, no como traducción literal +- [ ] **Consistencia:** mismo término español para mismo concepto en inglés +- [ ] **Preposición:** "en Angular" en lugar de "de Angular" +- [ ] **Navegación:** si aplica, `sub-navigation-data.ts` actualizado +- [ ] **Git:** archivos `.md` y `.en.md` staged para el commit diff --git a/.gitignore b/.gitignore index 1fe1e6d0..1295bca9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ Thumbs.db # Firebase Caching .firebase + +# Configuración local de Claude Code (personal, no compartida) +.claude/settings.local.json diff --git a/glosario.yml b/glosario.yml index 7393b32b..194f0734 100644 --- a/glosario.yml +++ b/glosario.yml @@ -53,3 +53,34 @@ rules: - expected: Crear schematics pattern: Autoriza(ndo|r) schematics reason: '"authoring" es crear/desarrollar, no autorizar' + + # Los prefijos de alerta son claves del tokenizer de adev, no prosa. + # docs-alert.mts solo reconoce las inglesas; traducirlas rompe el renderizado + # y el aviso sale como párrafo plano. + - expected: 'NOTE:' + pattern: '^\s*NOTA:' + reason: 'prefijo de alerta: es clave del tokenizer, debe quedar en inglés' + + - expected: 'TIP:' + pattern: '^\s*CONSEJO:' + reason: 'prefijo de alerta: es clave del tokenizer, debe quedar en inglés' + + - expected: 'IMPORTANT:' + pattern: '^\s*IMPORTANTE:' + reason: 'prefijo de alerta: es clave del tokenizer, debe quedar en inglés' + + - expected: 'HELPFUL:' + pattern: '^\s*ÚTIL:' + reason: 'prefijo de alerta: es clave del tokenizer, debe quedar en inglés' + + - expected: 'SUMMARY:' + pattern: '^\s*RESUMEN:' + reason: 'prefijo de alerta: es clave del tokenizer, debe quedar en inglés' + + - expected: 'CRITICAL:' + pattern: '^\s*CRÍTICO:' + reason: 'prefijo de alerta: es clave del tokenizer, debe quedar en inglés' + + - expected: 'QUESTION:' + pattern: '^\s*PREGUNTA:' + reason: 'prefijo de alerta: es clave del tokenizer, debe quedar en inglés' From 098d4f36a657ca03d2f50b31f0847f96f725e8d1 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 20:56:11 -0400 Subject: [PATCH 04/28] =?UTF-8?q?feat:=20POC=20de=20traducci=C3=B3n=20incr?= =?UTF-8?q?emental=20por=20bloque?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convierte "este archivo está desactualizado" en "aplica estos N cambios concretos", sin retraducir el archivo. Probado end-to-end sobre los dos archivos que hoy están desactualizados de verdad. El principio es leer mucho y escribir poco. La orden de trabajo NO recorta el contexto: manda leer los dos documentos completos, porque la traducción existente es la mejor referencia sobre qué terminología y registro usa ESE documento. Lo acotado es la escritura: una edición por bloque, sobre texto copiado verbatim. tools/blocks.mjs - Segmenta markdown en bloques sin partir nunca fences ni contenedores . Maneja las tres formas del corpus, incluida la autocerrada con atributos multilínea. - Validado contra los 345 pares: 98.3% comparten esqueleto de encabezados y número de bloques. - 20 tests. Escribirlos ya valió la pena: destaparon que un fence de 4 backticks se cerraba con el ``` interno, que habría partido bloques en los archivos que muestran markdown dentro de markdown. tools/plan-translation.mjs - Calcula el baseline como el commit más reciente que tocó .md Y .en.md, no solo el .md. Sin eso, un commit de mantenimiento sobre la traducción mueve la referencia y el trabajo pendiente desaparece en silencio. - Expande cada hunk al bloque que lo contiene y localiza el bloque español por ancla de sección más posición, confirmándolo con tres verificaciones. Si alguna falla, marca manual y no entra: nunca adivina. - Rechaza como reestructuración lo que supere 8 bloques cambiados. tools/verify-translation.mjs - AISLAMIENTO: toda línea modificada debe caer en un bloque autorizado. Convierte "no rompas el resto del archivo" de promesa del prompt en invariante verificable, que es justo lo que angular-ja no tiene. Usa las coordenadas del lado viejo del diff, que corresponden exactamente a los rangos registrados: sin heurísticas de desplazamiento. - Estructura, anclas y glosario. - El glosario se verifica SOLO sobre las líneas tocadas. Verificar el archivo entero hace fallar cada edición por deuda que el autor no introdujo, y un chequeo que siempre falla acaba ignorado. Lo encontramos ejecutándolo: selectors.md fallaba por un CONSEJO: de la línea 3. Resultado sobre los dos archivos desactualizados de hoy: selectors.md listo 1 bloque de 37 drag-drop.md manual 42 bloques cambiados, es reestructuración El caso completo se ejecutó de verdad —editar, verificar, y comprobar que el aislamiento detecta una edición fuera de rango— y luego se revirtió el contenido: #192 también modifica selectors.md, así que cualquier cambio al corpus ahora es un conflicto. Este commit es solo herramientas. Nota para la revisión de #192: mergea 406 callouts con el prefijo traducido, que no se renderizan como caja de aviso. --- .claude/skills/translate-delta.md | 118 ++++++++++ .gitignore | 3 + package.json | 6 +- tools/blocks.mjs | 211 +++++++++++++++++ tools/blocks.test.mjs | 160 +++++++++++++ tools/lint-glossary.mjs | 6 +- tools/plan-translation.mjs | 362 ++++++++++++++++++++++++++++++ tools/verify-translation.mjs | 253 +++++++++++++++++++++ 8 files changed, 1116 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/translate-delta.md create mode 100644 tools/blocks.mjs create mode 100644 tools/blocks.test.mjs create mode 100644 tools/plan-translation.mjs create mode 100644 tools/verify-translation.mjs diff --git a/.claude/skills/translate-delta.md b/.claude/skills/translate-delta.md new file mode 100644 index 00000000..a7685c9b --- /dev/null +++ b/.claude/skills/translate-delta.md @@ -0,0 +1,118 @@ +--- +name: translate-delta +description: Aplicar a la traducción española solo los cambios del original en inglés, sin retraducir el archivo +--- + +# Traducir el delta, no el archivo + +Cuando el original en inglés cambia, la traducción española **no se rehace**: se le aplica +únicamente el cambio. El resto del archivo ya está bien y suele contener refinamiento humano +acumulado que retraducir destruiría. + +Este skill es para archivos **ya traducidos que quedaron desactualizados**. Para traducir un +archivo desde cero usa [`translate-angular-docs`](./translate-angular-docs.md). + +## El principio: leer mucho, escribir poco + +| | | +| --- | --- | +| Lo que **lees** | el documento completo, en ambos idiomas | +| Lo que **escribes** | solo los bloques que la orden autoriza | + +Leer entero es barato — la mediana del corpus son ~1.200 tokens — y es lo que evita la deriva +terminológica: te dice qué convenciones usa **ese** documento en concreto. Lo que está acotado +es la escritura. + +## Flujo + +### Paso 1 — Generar la orden de trabajo + +```shell +npx zx tools/plan-translation.mjs +``` + +Produce `.translation-plan/.md` con los bloques a tocar. Estados posibles: + +- `listo` → sigue con el paso 2 +- `manual` → el anclaje no es seguro (esqueleto distinto, demasiados bloques, reestructuración). + **No lo fuerces.** Escala a revisión humana; el mecanismo incremental no aplica aquí. +- `solo-ruido` → el cambio inglés no toca contenido. No hay nada que hacer. + +### Paso 2 — Leer antes de escribir + +Lee **completos**, con la herramienta Read: + +1. El `.md` español — es tu referencia de estilo, registro y terminología. +2. El `.en.md` inglés — para entender el contexto del cambio. +3. La orden en `.translation-plan/`. + +### Paso 3 — Aplicar cada edición + +Una llamada `Edit` por item. El `old_string` es el bloque **«Español actual» copiado verbatim** +de la orden; el `new_string` es ese mismo bloque con el cambio mínimo aplicado. + +Esto es una tarea de **edición**, no de traducción. Tienes el inglés anterior, el inglés nuevo y +el español actual: reproduce en español el mismo cambio que ocurrió en inglés, tocando lo menos +posible. + +Si `Edit` falla por coincidencia no única, amplía el `old_string` con el bloque anterior. Si +falla por no encontrar el texto, **detente**: significa que el archivo no está como la orden +supone, y seguir corrompería el documento. + +### Paso 4 — Declarar el resultado + +Para cada item, di explícitamente cuál de estos fue: + +| Resultado | Cuándo | +| --- | --- | +| `editado` | se aplicó el cambio | +| `sin-cambio` | el cambio inglés no afecta al español: reflujo de párrafo, migración de `` a fence, normalización de URL | +| `ya-aplicado` | la traducción ya reflejaba el cambio | +| `no-puedo` | no es seguro; escala a humano | + +> [!WARNING] +> En un item `sin-cambio`, **no copies el inglés al español**. Es la forma más fácil de publicar +> inglés en la página española, y ninguna verificación estructural lo detecta. + +### Paso 5 — Verificar + +```shell +npx zx tools/verify-translation.mjs +``` + +Comprueba cuatro cosas. Si alguna falla, **no commitees**: + +- **aislamiento** — toda línea modificada cae dentro de un bloque autorizado +- **estructura** — el esqueleto de encabezados, el número de bloques y las etiquetas `` + siguen correspondiendo al inglés +- **anclas** — los enlaces internos `#slug` resuelven contra encabezados que existen +- **glosario** — terminología correcta en las líneas que tocaste (la deuda heredada se reporta + pero no bloquea) + +### Paso 6 — Commitear + +`.md` y `.en.md` **en el mismo commit**. Romper ese invariante deja el archivo marcado como +desactualizado para siempre: es exactamente el origen del falso positivo de `selectors.md`. + +## Reglas de edición + +Aplica el glosario de [`translate-angular-docs`](./translate-angular-docs.md), más estas +específicas del delta: + +| Regla | Por qué | +| --- | --- | +| No cambies la estructura markdown ni el número de bloques | El direccionamiento futuro depende de ello | +| Mantén el mismo número de líneas que el bloque inglés nuevo | Sostiene la paridad del 91 % del corpus | +| Dentro de fences: traduce comentarios y textos de interfaz, nunca código ni identificadores | Práctica establecida del corpus | +| `title=` y `header=` se traducen si son prosa, **no** si son rutas | 237 de 237 `docs-step title` están traducidos | +| `path=`, `region=`, `visibleRegion=` nunca se traducen | Son identificadores | +| Los encabezados conservan su `{#ancla}` intacta | El ancla es la clave de direccionamiento entre idiomas | +| Prefijos de alerta (`NOTE:`, `TIP:`, `IMPORTANT:`, `HELPFUL:`…) **en inglés** | Son claves del tokenizer, no prosa: traducirlas rompe el renderizado | +| No cambies el destino de un enlace salvo que el diff inglés lo cambie | Hay ~96 enlaces localizados a propósito | + +## Cuándo NO usar este skill + +- El archivo no tiene `.en.md` → no está traducido; usa `translate-angular-docs`. +- La orden dice `manual` → el anclaje no es fiable. +- El cambio inglés es una reestructuración (más de 8 bloques) → retraducir esa sección con la + traducción existente delante como referencia sale mejor que parchear. diff --git a/.gitignore b/.gitignore index 1295bca9..eae24149 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ Thumbs.db # Configuración local de Claude Code (personal, no compartida) .claude/settings.local.json + +# Órdenes de traducción generadas (efímeras, no se versionan) +.translation-plan/ diff --git a/package.json b/package.json index f941403e..790dc34d 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,16 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node --test tools/*.test.mjs", "build": "zx tools/build.mjs", "start": "zx tools/watch.mjs", "update-origin": "zx tools/update-origin.mjs", "check-translations": "zx tools/check-translations.mjs", "lint-glossary": "zx tools/lint-glossary.mjs", "deploy:staging": "firebase use staging && firebase deploy --only hosting", - "deploy:prod": "firebase use production && firebase deploy --only hosting" + "deploy:prod": "firebase use production && firebase deploy --only hosting", + "plan-translation": "zx tools/plan-translation.mjs", + "verify-translation": "zx tools/verify-translation.mjs" }, "keywords": [], "author": "", diff --git a/tools/blocks.mjs b/tools/blocks.mjs new file mode 100644 index 00000000..4b43b074 --- /dev/null +++ b/tools/blocks.mjs @@ -0,0 +1,211 @@ +/** + * Segmenta un markdown en bloques. + * + * Un bloque es la unidad de direccionamiento del flujo incremental: los hunks de + * un diff se expanden al bloque que los contiene, y la verificación exige que + * toda edición caiga dentro de los bloques declarados. + * + * Regla de corte: línea en blanco a nivel superior. Nunca se corta dentro de un + * fence ni de un contenedor ``, porque partirlos produce markdown + * inválido — es el fallo de diseño de trocear por líneas del diff. + * + * Las líneas son 1-indexadas y los rangos inclusivos, para que coincidan con lo + * que reportan git y los editores. + */ + +/** Tipos de bloque. El tipo se usa para confirmar el anclaje entre idiomas. */ +export const KIND = { + HEADING: 'heading', + FENCE: 'fence', + CONTAINER: 'container', + TABLE: 'table', + LIST: 'list', + PARAGRAPH: 'paragraph', +}; + +const FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/; +const HEADING = /^\s{0,3}(#{1,6})\s+(.*)$/; +const DOCS_OPEN = /^\s*<(docs-[\w-]+)/; +const ANCHOR = /\{#\s*([\w-]+)\s*\}/; + +/** + * @param {string} text + * @returns {Array<{start:number,end:number,text:string,kind:string,meta:object}>} + */ +export function parseBlocks(text) { + const lines = text.split('\n'); + const blocks = []; + let i = 0; + + while (i < lines.length) { + if (lines[i].trim() === '') { + i++; + continue; + } + + const start = i; + let end; + let kind; + let meta = {}; + + const fence = lines[i].match(FENCE_OPEN); + const heading = lines[i].match(HEADING); + + if (fence) { + kind = KIND.FENCE; + meta.lang = (fence[3] || '').trim().split(/[\s{]/)[0] || null; + end = closeFence(lines, i, fence[2]); + } else if (DOCS_OPEN.test(lines[i])) { + kind = KIND.CONTAINER; + const tag = lines[i].match(DOCS_OPEN)[1]; + meta.tag = tag; + end = closeContainer(lines, i, tag); + } else if (heading) { + kind = KIND.HEADING; + meta.level = heading[1].length; + meta.title = heading[2].trim(); + const anchor = meta.title.match(ANCHOR); + meta.anchor = anchor ? anchor[1] : null; + end = i; // un encabezado es siempre un bloque de una línea + } else { + end = closeProse(lines, i); + kind = classifyProse(lines.slice(i, end + 1)); + } + + blocks.push({ + start: start + 1, + end: end + 1, + text: lines.slice(start, end + 1).join('\n'), + kind, + meta, + }); + + i = end + 1; + } + + return blocks; +} + +/** + * Busca el cierre de un fence. Solo cierra un fence del mismo carácter y de + * longitud igual o mayor: es lo que permite anidar ``` dentro de ````, que el + * corpus usa para mostrar markdown dentro de markdown. Sin cierre, el bloque + * llega al final del archivo. + */ +function closeFence(lines, open, marker) { + const char = marker[0] === '`' ? '`' : '~'; + const closer = new RegExp(`^\\s*\\${char}{${marker.length},}\\s*$`); + for (let i = open + 1; i < lines.length; i++) { + if (closer.test(lines[i])) return i; + } + return lines.length - 1; +} + +/** + * Busca el cierre de un contenedor ``. Hay tres formas en el corpus y + * las tres tienen que funcionar o el archivo entero cae al carril lento: + * + * autocerrado en una línea + * emparejado + * autocerrado con atributos multilínea + */ +function closeContainer(lines, open, tag) { + // Primero hay que ver dónde termina la etiqueta de apertura, que puede + // abarcar varias líneas. + let tagEnd = -1; + let selfClosing = false; + let buf = ''; + + for (let i = open; i < lines.length; i++) { + buf += lines[i]; + const gt = buf.indexOf('>'); + if (gt !== -1) { + tagEnd = i; + selfClosing = buf[gt - 1] === '/'; + break; + } + buf += '\n'; + } + + if (tagEnd === -1) return lines.length - 1; // etiqueta sin cerrar + if (selfClosing) return tagEnd; + + // Emparejado: contar anidamiento del mismo tag. + const openRe = new RegExp(`<${tag}(?![\\w-])`, 'g'); + const closeRe = new RegExp(``, 'g'); + let depth = 0; + + for (let i = open; i < lines.length; i++) { + const line = lines[i]; + depth += (line.match(openRe) || []).length; + // Las aperturas autocerradas no aumentan la profundidad. + depth -= (line.match(new RegExp(`<${tag}(?![\\w-])[^>]*/>`, 'g')) || []).length; + depth -= (line.match(closeRe) || []).length; + if (i >= tagEnd && depth <= 0) return i; + } + + return lines.length - 1; // contenedor sin cerrar +} + +/** La prosa termina en la primera línea en blanco. */ +function closeProse(lines, open) { + let i = open; + while (i + 1 < lines.length && lines[i + 1].trim() !== '') { + // Un fence o un que arranca sin línea en blanco antes corta aquí. + if (FENCE_OPEN.test(lines[i + 1]) || DOCS_OPEN.test(lines[i + 1]) || HEADING.test(lines[i + 1])) { + break; + } + i++; + } + return i; +} + +function classifyProse(chunk) { + const first = chunk[0].trim(); + if (first.startsWith('|')) return KIND.TABLE; + if (/^([-*+]|\d+\.)\s/.test(first)) return KIND.LIST; + return KIND.PARAGRAPH; +} + +/** + * Esqueleto de encabezados: la firma estructural que debe coincidir entre el + * `.md` y el `.en.md`. Usa el ancla `{#id}` cuando existe, porque es idéntica en + * ambos idiomas por diseño; si no, cae al nivel + posición. + */ +export function headingSkeleton(blocks) { + return blocks + .filter((b) => b.kind === KIND.HEADING) + .map((b, idx) => (b.meta.anchor ? `${b.meta.level}#${b.meta.anchor}` : `${b.meta.level}@${idx}`)); +} + +/** + * Divide los bloques en secciones. Una sección arranca en cada encabezado y + * contiene los bloques hasta el siguiente. Es la unidad de anclaje. + */ +export function sections(blocks) { + const out = []; + let current = { heading: null, blocks: [] }; + + for (const b of blocks) { + if (b.kind === KIND.HEADING) { + if (current.heading || current.blocks.length) out.push(current); + current = { heading: b, blocks: [] }; + } else { + current.blocks.push(b); + } + } + if (current.heading || current.blocks.length) out.push(current); + + return out; +} + +/** Clave estable de una sección, para emparejar español con inglés. */ +export function sectionKey(section, index) { + if (!section.heading) return `@preamble`; + return section.heading.meta.anchor ? `#${section.heading.meta.anchor}` : `@${index}`; +} + +/** El bloque que contiene una línea dada (1-indexada). */ +export function blockAt(blocks, line) { + return blocks.find((b) => line >= b.start && line <= b.end) ?? null; +} diff --git a/tools/blocks.test.mjs b/tools/blocks.test.mjs new file mode 100644 index 00000000..e6cbd9dd --- /dev/null +++ b/tools/blocks.test.mjs @@ -0,0 +1,160 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseBlocks, headingSkeleton, sections, blockAt, KIND } from './blocks.mjs'; + +const kinds = (md) => parseBlocks(md).map((b) => b.kind); +const texts = (md) => parseBlocks(md).map((b) => b.text); + +test('separa párrafos por línea en blanco', () => { + assert.deepEqual(kinds('uno\n\ndos\n\ntres'), ['paragraph', 'paragraph', 'paragraph']); +}); + +test('un fence es un solo bloque aunque contenga líneas en blanco', () => { + const md = '```ts\nconst a = 1;\n\nconst b = 2;\n```'; + const blocks = parseBlocks(md); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].kind, KIND.FENCE); + assert.equal(blocks[0].meta.lang, 'ts'); + assert.equal(blocks[0].end, 5); +}); + +test('un fence con atributos conserva solo el lenguaje', () => { + const blocks = parseBlocks("```angular-ts {header:'app.ts'}\nx\n```"); + assert.equal(blocks[0].meta.lang, 'angular-ts'); +}); + +test('no confunde un ``` interno de mayor longitud', () => { + const md = '````md\n```ts\nx\n```\n````'; + assert.equal(parseBlocks(md).length, 1); +}); + +// --- las tres formas de que existen en el corpus --- + +test('docs-code autocerrado en una línea', () => { + const md = '\n\notro'; + const b = parseBlocks(md); + assert.equal(b.length, 2); + assert.equal(b[0].kind, KIND.CONTAINER); + assert.equal(b[0].end, 1); +}); + +test('docs-code emparejado', () => { + const md = '\n ng add x\n'; + const b = parseBlocks(md); + assert.equal(b.length, 1); + assert.equal(b[0].meta.tag, 'docs-code'); + assert.equal(b[0].end, 3); +}); + +test('docs-code autocerrado con atributos multilínea', () => { + const md = '\n\nsiguiente'; + const b = parseBlocks(md); + assert.equal(b.length, 2, 'la etiqueta multilínea debe ser un único bloque'); + assert.equal(b[0].end, 4); + assert.equal(b[1].text, 'siguiente'); +}); + +test('docs-code-multifile anidando docs-code', () => { + const md = [ + '', + ' ', + ' ', + '', + ].join('\n'); + const b = parseBlocks(md); + assert.equal(b.length, 1, 'el contenedor externo absorbe los internos'); + assert.equal(b[0].meta.tag, 'docs-code-multifile'); +}); + +test('contenedor con línea en blanco dentro no se parte', () => { + const md = '\nuno\n\ndos\n'; + assert.equal(parseBlocks(md).length, 1); +}); + +test('contenedor sin cerrar llega al final sin colgarse', () => { + const b = parseBlocks('\ncontenido\nmás'); + assert.equal(b.length, 1); + assert.equal(b[0].end, 3); +}); + +// --- encabezados y anclas --- + +test('el encabezado es un bloque de una línea', () => { + const b = parseBlocks('# Título\ntexto pegado'); + assert.equal(b.length, 2); + assert.equal(b[0].kind, KIND.HEADING); + assert.equal(b[0].meta.level, 1); + assert.equal(b[1].text, 'texto pegado'); +}); + +test('extrae el ancla explícita', () => { + const b = parseBlocks('## Primeros pasos {#get-started}'); + assert.equal(b[0].meta.anchor, 'get-started'); +}); + +test('el esqueleto usa el ancla cuando existe y la posición cuando no', () => { + const es = parseBlocks('# A {#a}\n\ntexto\n\n## B {#b}'); + const en = parseBlocks('# A {#a}\n\nprose\n\n## B {#b}'); + assert.deepEqual(headingSkeleton(es), headingSkeleton(en)); + assert.deepEqual(headingSkeleton(es), ['1#a', '2#b']); +}); + +test('el esqueleto detecta divergencia estructural', () => { + const a = headingSkeleton(parseBlocks('# X\n\n## Y')); + const b = headingSkeleton(parseBlocks('# X\n\n### Y')); + assert.notDeepEqual(a, b); +}); + +// --- listas y tablas --- + +test('distingue listas y tablas de párrafos', () => { + assert.deepEqual(kinds('- uno\n- dos\n\n| a | b |\n|---|---|\n\nprosa'), ['list', 'table', 'paragraph']); +}); + +// --- secciones y localización --- + +test('agrupa bloques en secciones por encabezado', () => { + const b = parseBlocks('# A\n\nuno\n\n## B\n\ndos\n\ntres'); + const s = sections(b); + assert.equal(s.length, 2); + assert.equal(s[0].blocks.length, 1); + assert.equal(s[1].blocks.length, 2); +}); + +test('el texto previo a cualquier encabezado forma su propia sección', () => { + const s = sections(parseBlocks('intro suelta\n\n# A\n\nuno')); + assert.equal(s.length, 2); + assert.equal(s[0].heading, null); +}); + +test('blockAt localiza el bloque que contiene una línea', () => { + const b = parseBlocks('uno\n\ndos\n\ntres'); + assert.equal(blockAt(b, 3).text, 'dos'); + assert.equal(blockAt(b, 2), null, 'la línea en blanco no pertenece a ningún bloque'); +}); + +// --- invariante de rangos --- + +test('los rangos son contiguos, no se solapan y cubren todo el contenido', () => { + const md = '# T\n\npárrafo\n\n```ts\nx\n```\n\n\n\n- l1\n- l2'; + const b = parseBlocks(md); + for (let i = 1; i < b.length; i++) { + assert.ok(b[i].start > b[i - 1].end, `bloque ${i} se solapa con el anterior`); + } + const lines = md.split('\n'); + const covered = new Set(); + for (const blk of b) for (let l = blk.start; l <= blk.end; l++) covered.add(l); + lines.forEach((line, idx) => { + if (line.trim() !== '') { + assert.ok(covered.has(idx + 1), `línea ${idx + 1} sin cubrir: ${JSON.stringify(line)}`); + } + }); +}); + +test('el texto de cada bloque coincide con sus líneas declaradas', () => { + const md = '# T\n\npárrafo largo\ncon dos líneas\n\n```ts\nx\n```'; + const lines = md.split('\n'); + for (const b of parseBlocks(md)) { + assert.equal(b.text, lines.slice(b.start - 1, b.end).join('\n')); + } +}); diff --git a/tools/lint-glossary.mjs b/tools/lint-glossary.mjs index d83fd16a..a9df6c63 100644 --- a/tools/lint-glossary.mjs +++ b/tools/lint-glossary.mjs @@ -37,7 +37,11 @@ try { findings.push(...lintFile(file, text, rules)); } - report(findings, files.length); + if (argv.json) { + console.log(JSON.stringify({ scanned: files.length, findings }, null, 2)); + } else { + report(findings, files.length); + } process.exit(findings.length > 0 ? 1 : 0); } catch (err) { console.error(chalk.red(err)); diff --git a/tools/plan-translation.mjs b/tools/plan-translation.mjs new file mode 100644 index 00000000..8d68bc1d --- /dev/null +++ b/tools/plan-translation.mjs @@ -0,0 +1,362 @@ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, resolve } from 'node:path'; +import { $, argv, chalk, YAML } from 'zx'; +import { parseBlocks, headingSkeleton, sections, sectionKey, KIND } from './blocks.mjs'; + +/** + * Convierte "este archivo está desactualizado" en una orden de trabajo concreta: + * qué bloques del español hay que tocar, con su antes y su después. + * + * El principio es leer mucho y escribir poco. La orden NO recorta el contexto: + * apunta a los documentos completos en ambos idiomas, porque la traducción + * existente es la mejor referencia disponible sobre qué terminología, registro y + * convenciones usa ESE documento. Lo que sí se acota es la escritura: cada item + * es una edición puntual sobre un bloque identificado verbatim. + * + * Uso: + * npx zx tools/plan-translation.mjs guide/components/selectors.md + * npx zx tools/plan-translation.mjs --all + * npx zx tools/plan-translation.mjs --all --json + */ + +$.verbose = false; + +const CONTENT = 'adev-es/src/content'; +const OUT_DIR = '.translation-plan'; +const ROOT = resolve(import.meta.dirname, '..'); + +// Por encima de este tamaño el bloque deja de ser una unidad razonable de +// edición y el archivo se manda a revisión manual en vez de fingir precisión. +const MAX_BLOCK_LINES = 60; +// Por encima de esto el cambio es una reestructuración, no un delta. +const MAX_CHANGED_BLOCKS = 8; + +try { + const targets = argv.all ? await staleFiles() : [normalize(argv._[0])]; + if (!targets[0]) { + console.error(chalk.red('Indica un archivo o usa --all')); + process.exit(1); + } + + const results = []; + for (const md of targets) { + results.push(await planFile(md)); + } + + if (argv.json) { + console.log(JSON.stringify(results, null, 2)); + } else { + summarize(results); + } + + process.exit(results.some((r) => r.status === 'error') ? 1 : 0); +} catch (err) { + console.error(chalk.red(err.stack ?? err)); + process.exit(1); +} + +/* ------------------------------------------------------------------ */ + +function normalize(p) { + if (!p) return null; + return p.startsWith(CONTENT) ? p : `${CONTENT}/${p}`; +} + +/** Los archivos que `check-translations` marca como desactualizados. */ +async function staleFiles() { + const out = await $`npx zx tools/check-translations.mjs --json`.nothrow(); + const data = JSON.parse(out.stdout); + return data.stale.map((s) => s.file); +} + +/** + * El commit desde el que medir. Tiene que ser el más reciente que tocó el `.md` + * Y el `.en.md`: si solo se exige el `.md`, un commit de mantenimiento sobre la + * traducción mueve la referencia y el trabajo pendiente desaparece en silencio. + */ +async function baselineFor(md, en) { + const commits = (await $`git log --format=%H -- ${md}`.nothrow()).stdout.trim().split('\n').filter(Boolean); + for (const c of commits) { + const touched = (await $`git show --name-only --format= ${c} -- ${en}`.nothrow()).stdout.trim(); + if (touched) return c; + } + // Sin commit conjunto, el origen es donde nació el snapshot inglés. + const first = (await $`git log --format=%H --diff-filter=A -- ${en}`.nothrow()).stdout.trim().split('\n').pop(); + return first || null; +} + +async function planFile(md) { + const en = md.replace(/\.md$/, '.en.md'); + const rel = md.replace(`${CONTENT}/`, ''); + + const baseline = await baselineFor(md, en); + if (!baseline) return { file: md, rel, status: 'error', reason: 'sin baseline en el historial' }; + + const enBase = (await $`git show ${baseline}:${en}`.nothrow()).stdout; + const enNow = (await $`git show HEAD:${en}`.nothrow()).stdout; + const es = readFileSync(resolve(ROOT, md), 'utf8'); + + if (!es.trim()) return { file: md, rel, status: 'error', reason: 'la traducción está vacía' }; + if (enBase === enNow) return { file: md, rel, status: 'sincronizado' }; + + const bNow = parseBlocks(enNow); + const bEs = parseBlocks(es); + + // Verificación 1: el esqueleto de encabezados debe coincidir. Si no, el + // direccionamiento por sección no es fiable y no se sigue adelante. + const skelEn = headingSkeleton(bNow); + const skelEs = headingSkeleton(bEs); + if (JSON.stringify(skelEn) !== JSON.stringify(skelEs)) { + return { + file: md, rel, baseline: baseline.slice(0, 7), status: 'manual', + reason: `esqueleto de encabezados distinto (inglés ${skelEn.length}, español ${skelEs.length})`, + }; + } + + const hunks = await hunkRanges(baseline, en); + const changed = expandToBlocks(hunks, bNow); + + if (changed.length === 0) { + return { file: md, rel, baseline: baseline.slice(0, 7), status: 'solo-ruido', + reason: 'los cambios no caen en ningún bloque de contenido' }; + } + if (changed.length > MAX_CHANGED_BLOCKS) { + return { + file: md, rel, baseline: baseline.slice(0, 7), status: 'manual', + reason: `${changed.length} bloques cambiados: es una reestructuración, no un delta`, + }; + } + + const secNow = sections(bNow); + const secEs = sections(bEs); + const bBase = parseBlocks(enBase); + + const items = []; + for (const blk of changed) { + const item = locate(blk, secNow, secEs, bBase); + items.push(item); + } + + const blocked = items.filter((i) => i.anchor !== 'confirmada'); + const status = blocked.length ? 'manual' : 'listo'; + + const plan = { + file: md, rel, en, baseline: baseline.slice(0, 7), status, + blocks: { en: bNow.length, es: bEs.length }, + items, + ...(blocked.length ? { reason: `${blocked.length} de ${items.length} bloques sin anclaje seguro` } : {}), + }; + + if (status === 'listo') writePlan(plan, es); + return plan; +} + +/** Rangos de líneas tocados, en coordenadas del inglés actual. */ +async function hunkRanges(baseline, en) { + const diff = (await $`git diff -U0 ${baseline} HEAD -- ${en}`.nothrow()).stdout; + const ranges = []; + for (const line of diff.split('\n')) { + const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + if (!m) continue; + const start = Number(m[1]); + const count = m[2] === undefined ? 1 : Number(m[2]); + // Una eliminación pura no ocupa líneas nuevas: se ancla a la frontera. + ranges.push(count === 0 ? { start, end: start + 1 } : { start, end: start + count - 1 }); + } + return ranges; +} + +/** Expande cada hunk al bloque que lo contiene, sin duplicar. */ +function expandToBlocks(ranges, blocks) { + const hit = new Map(); + for (const r of ranges) { + for (const b of blocks) { + if (b.start <= r.end && b.end >= r.start) hit.set(b.start, b); + } + } + return [...hit.values()].sort((a, b) => a.start - b.start); +} + +/** + * Localiza el bloque español que corresponde a un bloque inglés, y confirma el + * anclaje con verificaciones deterministas. Ante la duda no se adivina: se marca + * incierto y el archivo no entra al carril automático. + */ +function locate(blk, secNow, secEs, bBase) { + const si = secNow.findIndex((s) => s.blocks.includes(blk) || s.heading === blk); + const sec = secNow[si]; + const key = sectionKey(sec, si); + + // La sección española se busca por ancla si la hay; si no, por posición. + let esIdx = key.startsWith('#') ? secEs.findIndex((s, i) => sectionKey(s, i) === key) : si; + const esSec = secEs[esIdx]; + + const base = { + kind: blk.kind, + lines: `${blk.start}-${blk.end}`, + section: sec?.heading?.meta.title ?? '(preámbulo)', + sectionKey: key, + enNow: blk.text, + enBefore: matchInBase(blk, bBase), + }; + + if (!esSec) return { ...base, anchor: 'incierta', why: `sección ${key} no existe en español` }; + + // El encabezado mismo es su propio bloque. + if (sec.heading === blk) { + if (!esSec.heading) return { ...base, anchor: 'incierta', why: 'sin encabezado español' }; + return { ...base, anchor: 'confirmada', esLines: `${esSec.heading.start}-${esSec.heading.end}`, es: esSec.heading.text }; + } + + // Verificación 2: misma cantidad de bloques en la sección. + if (sec.blocks.length !== esSec.blocks.length) { + return { ...base, anchor: 'incierta', + why: `la sección tiene ${sec.blocks.length} bloques en inglés y ${esSec.blocks.length} en español` }; + } + + const pos = sec.blocks.indexOf(blk); + const esBlk = esSec.blocks[pos]; + + // Verificación 3: mismo tipo de bloque en la misma posición. + if (esBlk.kind !== blk.kind) { + return { ...base, anchor: 'incierta', why: `posición ${pos}: inglés ${blk.kind}, español ${esBlk.kind}` }; + } + if (blk.kind === KIND.CONTAINER && esBlk.meta.tag !== blk.meta.tag) { + return { ...base, anchor: 'incierta', why: `contenedor distinto: ${blk.meta.tag} vs ${esBlk.meta.tag}` }; + } + + if (esBlk.end - esBlk.start + 1 > MAX_BLOCK_LINES) { + return { ...base, anchor: 'incierta', why: `bloque de ${esBlk.end - esBlk.start + 1} líneas: demasiado grande para editar a ciegas` }; + } + + return { ...base, anchor: 'confirmada', esLines: `${esBlk.start}-${esBlk.end}`, es: esBlk.text, unique: isUnique(esBlk.text) }; + + function isUnique(text) { + // `Edit` exige coincidencia única; si el bloque se repite hay que ampliarlo. + const all = secEs.flatMap((s) => s.blocks).filter((b) => b.text === text); + return all.length === 1; + } +} + +/** El mismo bloque en el inglés anterior, para mostrar qué cambió. */ +function matchInBase(blk, bBase) { + const exact = bBase.find((b) => b.text === blk.text); + if (exact) return null; // no cambió + // Heurística: el bloque más parecido del mismo tipo cerca de la misma posición. + const same = bBase.filter((b) => b.kind === blk.kind); + let best = null; + let bestScore = 0; + for (const b of same) { + const score = similarity(b.text, blk.text); + if (score > bestScore) { bestScore = score; best = b; } + } + return bestScore > 0.35 ? best.text : null; +} + +function similarity(a, b) { + const wa = new Set(a.split(/\s+/)); + const wb = new Set(b.split(/\s+/)); + const inter = [...wa].filter((w) => wb.has(w)).length; + return inter / Math.max(wa.size, wb.size, 1); +} + +/* ------------------------------------------------------------------ */ + +function writePlan(plan, esText) { + const glossary = YAML.parse(readFileSync(resolve(ROOT, 'glosario.yml'), 'utf8')).rules; + const out = resolve(ROOT, OUT_DIR, `${plan.rel}.md`); + mkdirSync(dirname(out), { recursive: true }); + + const L = []; + L.push(`# Orden de traducción · ${plan.rel}`, ''); + L.push(`- **Editar:** \`${plan.file}\``); + L.push(`- **Original nuevo:** \`${plan.en}\``); + L.push(`- **Traducido por última vez en:** \`${plan.baseline}\``); + L.push(`- **Bloques a tocar:** ${plan.items.length} de ${plan.blocks.es}`, ''); + + L.push('## Antes de editar', ''); + L.push('**Lee los dos documentos completos**, no solo los bloques de abajo:', ''); + L.push(`1. \`${plan.file}\` — la traducción viva. Te dice qué terminología, registro y`); + L.push(' convenciones usa **este** documento en concreto. Espeja lo que ya hace.'); + L.push(`2. \`${plan.en}\` — el original actual, para entender el contexto del cambio.`, ''); + L.push('Leer entero es barato y evita la deriva terminológica. Lo que está acotado es la'); + L.push('**escritura**: solo se tocan los bloques listados, con una llamada `Edit` por item.', ''); + + L.push('## Reglas de terminología', ''); + for (const r of glossary) L.push(`- \`${r.pattern}\` → **${r.expected}** — ${r.reason}`); + L.push(''); + + L.push('## Ediciones', ''); + plan.items.forEach((it, n) => { + L.push(`### ${n + 1}. ${it.section} · líneas ${it.esLines} · \`${it.kind}\``, ''); + if (it.enBefore) { + L.push('**Inglés anterior** (lo que ya está traducido):', '', '```markdown', it.enBefore, '```', ''); + } else { + L.push('**Bloque nuevo** — no existía en el original anterior.', ''); + } + L.push('**Inglés actual** (lo que hay que reflejar):', '', '```markdown', it.enNow, '```', ''); + L.push('**Español actual** — cópialo verbatim como `old_string`:', '', '```markdown', it.es, '```', ''); + if (it.unique === false) { + L.push('> ⚠️ Este bloque no es único en el archivo. Amplía el `old_string` con el bloque', ''); + L.push('> anterior para que `Edit` pueda identificarlo sin ambigüedad.', ''); + } + L.push(''); + }); + + L.push('## Al terminar', ''); + L.push('Declara para cada item qué hiciste:', ''); + L.push('- `editado` — se aplicó el cambio'); + L.push('- `sin-cambio` — el cambio inglés no afecta al español (reflujo, migración de'); + L.push(' formato, cambio de URL). **No copies el inglés**: deja el bloque como está.'); + L.push('- `ya-aplicado` — la traducción ya reflejaba el cambio'); + L.push('- `no-puedo` — escala a revisión humana', ''); + L.push('Después ejecuta:', '', '```shell', `npx zx tools/verify-translation.mjs ${plan.rel}`, '```', ''); + + writeFileSync(out, L.join('\n')); + + // Sidecar para el verificador: los rangos declarados, tomados ANTES de editar. + // Sin esto no se puede comprobar el aislamiento, porque tras la edición las + // líneas se desplazan y ya no hay forma de reconstruir qué se autorizó tocar. + writeFileSync( + out.replace(/\.md$/, '.json'), + JSON.stringify( + { + file: plan.file, + en: plan.en, + baseline: plan.baseline, + esSha: sha(esText), + ranges: plan.items.map((it) => { + const [start, end] = it.esLines.split('-').map(Number); + return { start, end, kind: it.kind, section: it.section }; + }), + }, + null, + 2 + ) + ); + + plan.planPath = `${OUT_DIR}/${plan.rel}.md`; +} + +function sha(text) { + return createHash('sha1').update(text).digest('hex').slice(0, 12); +} + +function summarize(results) { + const icon = { listo: chalk.green('✔'), manual: chalk.yellow('~'), error: chalk.red('✘'), + 'solo-ruido': chalk.dim('·'), sincronizado: chalk.dim('=') }; + + console.log(chalk.cyan('\nÓrdenes de traducción\n')); + for (const r of results) { + console.log(` ${icon[r.status] ?? '?'} ${chalk.bold(r.rel)} ${chalk.dim(r.status)}`); + if (r.reason) console.log(chalk.dim(` ${r.reason}`)); + if (r.planPath) { + console.log(chalk.dim(` ${r.items.length} bloques de ${r.blocks.es} · ${r.planPath}`)); + for (const it of r.items) { + console.log(chalk.dim(` · ${it.section} (${it.kind}, líneas ${it.esLines})`)); + } + } + } + console.log(); +} diff --git a/tools/verify-translation.mjs b/tools/verify-translation.mjs new file mode 100644 index 00000000..98607071 --- /dev/null +++ b/tools/verify-translation.mjs @@ -0,0 +1,253 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { $, argv, chalk } from 'zx'; +import { parseBlocks, headingSkeleton, KIND } from './blocks.mjs'; + +/** + * Comprueba que una edición incremental hizo exactamente lo que declaró. + * + * El chequeo que da valor a todo el flujo es el de AISLAMIENTO: cada línea + * modificada del español debe caer dentro de un bloque que la orden autorizó + * tocar. Es lo que convierte "no rompas el resto del archivo" de promesa del + * prompt en invariante verificado — y es justo lo que angular-ja no tiene. + * + * Uso: + * npx zx tools/verify-translation.mjs guide/components/selectors.md + */ + +$.verbose = false; + +const CONTENT = 'adev-es/src/content'; +const OUT_DIR = '.translation-plan'; +const ROOT = resolve(import.meta.dirname, '..'); + +try { + const rel = (argv._[0] ?? '').replace(`${CONTENT}/`, ''); + if (!rel) { + console.error(chalk.red('Indica el archivo, p. ej. guide/components/selectors.md')); + process.exit(1); + } + + const sidecar = resolve(ROOT, OUT_DIR, `${rel}.json`); + if (!existsSync(sidecar)) { + console.error(chalk.red(`No hay orden de trabajo para ${rel}.`)); + console.error(chalk.dim(`Genérala con: npx zx tools/plan-translation.mjs ${rel}`)); + process.exit(1); + } + + const plan = JSON.parse(readFileSync(sidecar, 'utf8')); + const checks = []; + + checks.push(await isolation(plan)); + checks.push(structure(plan)); + checks.push(anchors(plan)); + checks.push(await glossary(plan)); + + report(rel, checks); + process.exit(checks.some((c) => c.status === 'fail') ? 1 : 0); +} catch (err) { + console.error(chalk.red(err.stack ?? err)); + process.exit(1); +} + +/* ------------------------------------------------------------------ */ + +/** + * E1 · AISLAMIENTO. Todo hunk del diff español debe caer dentro de un rango + * autorizado. Un solo byte fuera y falla. + * + * Se comprueba contra el árbol de trabajo, así que corre en local antes de + * commitear. Es un guardarraíl contra el error, no contra la mala fe: nada + * impide regenerar la orden después de editar. + */ +async function isolation(plan) { + const diff = (await $`git diff -U0 -- ${plan.file}`.nothrow()).stdout; + + if (!diff.trim()) { + return { name: 'aislamiento', status: 'skip', detail: 'sin cambios en el árbol de trabajo' }; + } + + // Se usan las coordenadas del lado VIEJO del diff, no del nuevo. Los rangos + // autorizados se registraron contra el archivo tal como estaba antes de + // editar, así que el lado viejo se corresponde exactamente con ellos y no hace + // falta ninguna tolerancia por desplazamiento de líneas. + const touched = []; + for (const line of diff.split('\n')) { + const m = line.match(/^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/); + if (!m) continue; + const start = Number(m[1]); + const count = m[2] === undefined ? 1 : Number(m[2]); + // Una inserción pura no ocupa líneas viejas: se ancla a la frontera, que + // git reporta como la línea anterior al punto de inserción. + touched.push(count === 0 ? { start, end: start + 1, insert: true } : { start, end: start + count - 1 }); + } + + const allowed = plan.ranges; + const outside = touched.filter((t) => !allowed.some((a) => t.start <= a.end && t.end >= a.start)); + + if (outside.length) { + return { + name: 'aislamiento', + status: 'fail', + detail: `${outside.length} cambio(s) fuera de los bloques autorizados`, + lines: outside.map((o) => `líneas ${o.start}-${o.end}`), + hint: 'La traducción se modificó donde la orden no lo permitía. Revisa el diff.', + }; + } + + return { name: 'aislamiento', status: 'pass', detail: `${touched.length} hunk(s), todos dentro de lo autorizado` }; +} + +/** E2 · ESTRUCTURA. El español debe seguir correspondiendo al inglés actual. */ +function structure(plan) { + const es = readFileSync(resolve(ROOT, plan.file), 'utf8'); + const en = readFileSync(resolve(ROOT, plan.en), 'utf8'); + + if (!es.trim()) { + return { name: 'estructura', status: 'fail', detail: 'el archivo español quedó vacío' }; + } + + const bEs = parseBlocks(es); + const bEn = parseBlocks(en); + const problems = []; + + const skEs = headingSkeleton(bEs); + const skEn = headingSkeleton(bEn); + if (JSON.stringify(skEs) !== JSON.stringify(skEn)) { + problems.push(`esqueleto de encabezados: ${skEs.length} en español, ${skEn.length} en inglés`); + } + + if (bEs.length !== bEn.length) { + problems.push(`número de bloques: ${bEs.length} en español, ${bEn.length} en inglés`); + } + + const fences = (es.match(/^\s*(`{3,}|~{3,})/gm) ?? []).length; + if (fences % 2 !== 0) problems.push(`fences sin balancear (${fences})`); + + const tags = (s) => { + const c = {}; + for (const m of s.matchAll(/<(docs-[\w-]+)/g)) c[m[1]] = (c[m[1]] ?? 0) + 1; + return c; + }; + const tEs = tags(es); + const tEn = tags(en); + for (const tag of new Set([...Object.keys(tEs), ...Object.keys(tEn)])) { + if ((tEs[tag] ?? 0) !== (tEn[tag] ?? 0)) { + problems.push(`<${tag}>: ${tEs[tag] ?? 0} en español, ${tEn[tag] ?? 0} en inglés`); + } + } + + return problems.length + ? { name: 'estructura', status: 'fail', detail: `${problems.length} divergencia(s)`, lines: problems } + : { name: 'estructura', status: 'pass', detail: `${bEs.length} bloques, correspondencia intacta` }; +} + +/** + * E3 · ANCLAS. Cada fragmento `#slug` interno debe resolver contra un encabezado + * que exista en el archivo español. No se exige igualdad con el inglés: eso + * reprobaría los enlaces correctamente localizados. + */ +function anchors(plan) { + const es = readFileSync(resolve(ROOT, plan.file), 'utf8'); + const blocks = parseBlocks(es); + + const available = new Set(); + for (const b of blocks) { + if (b.kind !== KIND.HEADING) continue; + if (b.meta.anchor) available.add(b.meta.anchor); + available.add(slug(b.meta.title)); + } + + const broken = []; + for (const m of es.matchAll(/\]\(#([\w-]+)\)/g)) { + if (!available.has(m[1])) broken.push(m[1]); + } + + return broken.length + ? { + name: 'anclas', + status: 'fail', + detail: `${broken.length} enlace(s) interno(s) rotos`, + lines: broken.map((b) => `#${b}`), + hint: 'Un fragmento roto es error de build en adev, no una degradación.', + } + : { name: 'anclas', status: 'pass', detail: `${available.size} destinos disponibles` }; +} + +function slug(title) { + return title + .replace(/\{#[^}]*\}/g, '') + .trim() + .toLowerCase() + .replace(/[`*_]/g, '') + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-|-$/g, ''); +} + +/** + * E5 · GLOSARIO, solo sobre las líneas que esta edición tocó. + * + * Verificar el archivo entero haría fallar cada edición incremental por deuda + * preexistente que el autor no introdujo — y un chequeo que siempre falla acaba + * ignorado. La deuda heredada se reporta aparte, sin bloquear. + */ +async function glossary(plan) { + const rel = plan.file.replace(`${CONTENT}/`, ''); + const out = await $`npx zx tools/lint-glossary.mjs ${rel} --json`.nothrow(); + + let findings = []; + try { + findings = JSON.parse(out.stdout).findings; + } catch { + return { name: 'glosario', status: 'skip', detail: 'no se pudo leer el linter' }; + } + + const touched = await touchedLines(plan.file); + const introduced = findings.filter((f) => touched.has(f.line)); + const inherited = findings.length - introduced.length; + + const detail = introduced.length + ? `${introduced.length} problema(s) en las líneas editadas` + : `sin problemas en lo editado${inherited ? ` (${inherited} heredado(s), no bloquean)` : ''}`; + + return introduced.length + ? { name: 'glosario', status: 'fail', detail, + lines: introduced.map((f) => `${f.line}:${f.col} ${f.found} → ${f.expected}`), + hint: `npx zx tools/lint-glossary.mjs ${rel}` } + : { name: 'glosario', status: 'pass', detail }; +} + +/** Números de línea que el árbol de trabajo modificó respecto a HEAD. */ +async function touchedLines(file) { + const diff = (await $`git diff -U0 -- ${file}`.nothrow()).stdout; + const lines = new Set(); + for (const l of diff.split('\n')) { + const m = l.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + if (!m) continue; + const start = Number(m[1]); + const count = m[2] === undefined ? 1 : Number(m[2]); + for (let i = 0; i < Math.max(count, 1); i++) lines.add(start + i); + } + return lines; +} + +/* ------------------------------------------------------------------ */ + +function report(rel, checks) { + const icon = { pass: chalk.green('✔'), fail: chalk.red('✘'), skip: chalk.dim('·') }; + console.log(chalk.cyan(`\nVerificación · ${rel}\n`)); + + for (const c of checks) { + console.log(` ${icon[c.status]} ${chalk.bold(c.name.padEnd(12))} ${c.detail}`); + for (const l of c.lines ?? []) console.log(chalk.dim(` ${l}`)); + if (c.hint && c.status === 'fail') console.log(chalk.dim(` → ${c.hint}`)); + } + + const failed = checks.filter((c) => c.status === 'fail'); + console.log(); + console.log( + failed.length + ? chalk.red(` ${failed.length} verificación(es) fallida(s). No commitees así.\n`) + : chalk.green(' Todo en orden. Recuerda commitear .md y .en.md juntos.\n') + ); +} From ee4f2b6a448bd302a46a232f04211615493984d3 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 21:44:59 -0400 Subject: [PATCH 05/28] fix: baseline correcto y bucket visible en check-translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Porta a check-translations el arreglo que ya tenía plan-translation. El baseline era "último commit que tocó el .md". Con esa regla, cualquier commit de mantenimiento sobre la traducción —un typo, un enlace— adelanta la referencia, y todo el inglés que había cambiado antes deja de contarse: el archivo pasa a "sincronizado" sin que nadie lo haya traducido. Ya ocurrió con 01c0889 (chore: remove Twitter/X references), que tocó varios .md sin sus .en.md. Ahora exige el commit más reciente que tocó AMBOS archivos, resuelto en una sola pasada de git log sobre las dos rutas. Si no hay commit conjunto, cae al commit que dio de alta el .en.md: desde ahí, todo cambio del original es trabajo pendiente. Efecto medible: el baseline de selectors.md pasa de ea3e52e a ca450f6. Aquí los blobs del .en.md coinciden entre ambos commits, así que el resultado no cambia; pero en cuanto un update-origin caiga entre medias, sí cambiaría, y en la dirección peligrosa. Además, los dos `continue` mudos pasan a un bucket `skipped` visible. Un archivo que no se pudo analizar NO está sincronizado, y dejarlo caer en silencio era peor que ruidoso: syncStale habría cerrado su issue con "la traducción ya está al día". --- tools/check-translations.mjs | 75 ++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs index f490444b..5941e593 100644 --- a/tools/check-translations.mjs +++ b/tools/check-translations.mjs @@ -64,6 +64,7 @@ try { const stale = []; const untranslated = []; + const skipped = []; let synced = 0; for (const md of sources) { @@ -74,32 +75,39 @@ try { continue; } - const lastEs = (await $`git log -1 --format=%H ${ref} -- ${md}`.nothrow()).stdout.trim(); - if (!lastEs) continue; + const base = await baselineFor(md, en, ref); + if (!base) { + skipped.push({ file: md, why: 'sin baseline en el historial' }); + continue; + } - const before = (await $`git rev-parse ${lastEs}:${en}`.nothrow()).stdout.trim(); + const before = (await $`git rev-parse ${base.sha}:${en}`.nothrow()).stdout.trim(); const now = (await $`git rev-parse ${ref}:${en}`.nothrow()).stdout.trim(); - if (!before || !now) continue; + if (!before || !now) { + skipped.push({ file: md, why: 'no se pudo leer el blob del original' }); + continue; + } if (before === now) { synced++; continue; } - const diff = (await $`git diff ${lastEs} ${ref} -- ${en}`.nothrow()).stdout; + const diff = (await $`git diff ${base.sha} ${ref} -- ${en}`.nothrow()).stdout; stale.push({ file: en, source: md, category: categorize(md), - since: lastEs.slice(0, 7), + since: base.sha.slice(0, 7), + baselineKind: base.kind, ...classify(diff), }); } if (argv.json) { - reportJson({ stale, untranslated, synced }); + reportJson({ stale, untranslated, skipped, synced }); } else { - report({ stale, untranslated, synced, ref }); + report({ stale, untranslated, skipped, synced, ref }); } const blocking = stale.filter((s) => s.prose > 0).length + untranslated.length; @@ -109,6 +117,38 @@ try { process.exit(1); } +/** + * El commit desde el que medir: el más reciente que tocó el `.md` Y el `.en.md`. + * + * Exigir ambos no es un detalle. Si solo se pide el `.md`, cualquier commit de + * mantenimiento sobre la traducción —un typo, un enlace— adelanta la referencia + * y todo el inglés que había cambiado antes deja de contarse. El archivo pasa a + * "sincronizado" sin que nadie lo haya traducido. Ya ocurrió: `01c0889` + * (chore: remove Twitter/X references) tocó varios `.md` sin sus `.en.md`. + * + * Se resuelve con una sola pasada de `git log` sobre ambas rutas. + */ +async function baselineFor(md, en, ref) { + const out = (await $`git log --format=%x00%H --name-only ${ref} -- ${md} ${en}`.nothrow()).stdout; + + for (const entry of out.split('\0')) { + const lines = entry.split('\n').map((l) => l.trim()).filter(Boolean); + if (!lines.length) continue; + const [sha, ...files] = lines; + if (files.includes(md) && files.includes(en)) return { sha, kind: 'ambos' }; + } + + // Sin commit conjunto, la referencia es donde nació el snapshot inglés: desde + // ahí, todo cambio del original es trabajo pendiente. + const added = (await $`git log --format=%H --diff-filter=A ${ref} -- ${en}`.nothrow()).stdout + .trim() + .split('\n') + .filter(Boolean) + .pop(); + + return added ? { sha: added, kind: 'alta-del-snapshot' } : null; +} + /** * Separa los cambios de prosa real del ruido de formato, para que el reporte * sea accionable en vez de un conteo de líneas inflado. Es una heurística @@ -137,11 +177,12 @@ function classify(diff) { * Solo incluye los desactualizados con cambios de prosa: los que solo cambiaron * de formato no ameritan abrirle un issue a nadie. */ -function reportJson({ stale, untranslated, synced }) { +function reportJson({ stale, untranslated, skipped, synced }) { console.log( JSON.stringify( { synced, + skipped, stale: stale .filter((s) => s.prose > 0) .map(({ source, category, since, prose, noise, diff }) => ({ @@ -165,7 +206,7 @@ function reportJson({ stale, untranslated, synced }) { ); } -function report({ stale, untranslated, synced, ref }) { +function report({ stale, untranslated, skipped, synced, ref }) { const relevant = stale.filter((s) => s.prose > 0); const cosmetic = stale.filter((s) => s.prose === 0); const short = (f) => f.replace(`${CONTENT_DIR}/`, ''); @@ -174,7 +215,19 @@ function report({ stale, untranslated, synced, ref }) { console.log(` ${chalk.green('✔')} Sincronizadas: ${synced}`); console.log(` ${chalk.yellow('~')} Solo formato: ${cosmetic.length}`); console.log(` ${chalk.red('✘')} Desactualizadas: ${relevant.length}`); - console.log(` ${chalk.red('✘')} Sin traducir: ${untranslated.length}\n`); + console.log(` ${chalk.red('✘')} Sin traducir: ${untranslated.length}`); + if (skipped.length) { + console.log(` ${chalk.magenta('?')} Sin analizar: ${skipped.length}`); + } + console.log(); + + // Nunca en silencio: un archivo que no se pudo analizar no está sincronizado, + // y dejarlo caer haría que su issue se cerrara como "ya está al día". + if (skipped.length) { + console.log(chalk.magenta.bold('Sin analizar — revisar a mano:\n')); + for (const s of skipped) console.log(` ${short(s.file)} ${chalk.dim(`· ${s.why}`)}`); + console.log(); + } if (relevant.length) { console.log(chalk.red.bold('Desactualizadas — el inglés cambió después de traducir:\n')); From 336d1060612e0436a3de1f8ef24e687e38d36b9f Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 22:06:22 -0400 Subject: [PATCH 06/28] fix: detectar los tres estados silenciosos que se contaban como correctos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-translations reconocía dos estados. Faltaban tres, y los tres pasaban por buenos: el archivo se reportaba como sincronizado o no aparecía en absoluto. SIN RESPALDO — el .md está en español pero no tiene .en.md. El próximo update-origin lo trata como no traducido y le escribe inglés encima. Es pérdida de trabajo, no deuda pendiente, así que va primero en el reporte y con el comando para recuperarlo. Se distingue por detección de idioma (palabras funcionales), no por adivinar. DESPAREJADA — existe el .en.md pero no su .md. El bucle recorría solo los .md, así que estos archivos eran invisibles. Salió al verificar lo demás: guide/i18n/translations-files.en.md tiene un typo (translations en plural) y por eso nunca emparejó con translation-files.md. Ese typo explica por qué la traducción llevaba meses sin respaldo. #192 lo arregla con un rename. HUÉRFANA — el original ya no existe upstream. update-origin nunca borra —verificado, no hay ni un rm ni un unlink— así que la página sigue publicada en español y su .en.md se compara consigo mismo para siempre. Requiere el submódulo; si no está inicializado se avisa en vez de dar el chequeo por bueno. No poder comprobarlo no es lo mismo que no tener huérfanas. Además, el listado incluye ahora los archivos NO trackeados sobre el árbol de trabajo. Con ls-tree, las páginas recién copiadas por update-origin eran invisibles: el reporte decía que todo estaba bien justo en el momento con más trabajo pendiente. Verificado con fixtures: origin simulado con 347 páginas omitiendo una —detecta esa y solo esa—, y archivo sin commitear —pasa de invisible a listado—. La primera versión del fixture usaba el glob '**/*.en.md', que se salta los archivos en la raíz de content; la herramienta no tiene ese bug porque lista por prefijo de ruta. --- tools/check-translations.mjs | 188 +++++++++++++++++++++++++++++++---- 1 file changed, 166 insertions(+), 22 deletions(-) diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs index 5941e593..c231be43 100644 --- a/tools/check-translations.mjs +++ b/tools/check-translations.mjs @@ -1,19 +1,34 @@ -import { $, chalk, argv } from 'zx'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { $, chalk, argv, glob } from 'zx'; + +const ROOT = resolve(import.meta.dirname, '..'); /** * Verifica el estado de sincronización de las traducciones. * - * Detecta dos problemas distintos que `update-origin` puede dejar atrás: + * Detecta los cinco estados que `update-origin` puede dejar atrás. Los tres + * últimos son silenciosos: sin ellos, esos archivos se cuentan como correctos. * - * 1. DESACTUALIZADAS — el archivo ya está traducido (existe `.en.md`) pero el - * inglés cambió después. `update-origin` sobrescribe el `.en.md` con el - * inglés nuevo, así que el `.en.md` tal como estaba en el commit donde se - * tocó por última vez el `.md` es el inglés vigente al traducir. El diff - * contra el `.en.md` actual es exactamente lo que falta traducir. + * 1. DESACTUALIZADA — ya traducida, pero el inglés cambió después. + * `update-origin` sobrescribe el `.en.md` con el inglés nuevo, así que el + * `.en.md` tal como estaba en el commit donde se tocó por última vez el `.md` + * es el inglés vigente al traducir. El diff contra el `.en.md` actual es + * exactamente lo que falta traducir. * * 2. SIN TRADUCIR — no existe `.en.md`, así que `update-origin` copió el inglés - * directamente al `.md`. Son páginas en inglés publicadas en el sitio - * español (típicamente páginas nuevas de una versión). + * directamente al `.md`. Páginas en inglés publicadas en el sitio español. + * + * 3. SIN RESPALDO — el `.md` ya está en español pero no tiene `.en.md`. El + * próximo `update-origin` la trata como no traducida y le escribe inglés + * encima: es pérdida de trabajo, no deuda pendiente. + * + * 4. DESPAREJADA — existe el `.en.md` pero no su `.md`. Casi siempre un typo al + * crear el respaldo, y deja la traducción sin proteger. + * + * 5. HUÉRFANA — el original ya no existe upstream. `update-origin` nunca borra, + * así que la página sigue publicada en español pese a haber desaparecido de + * angular.dev, y su `.en.md` se compara consigo mismo para siempre. * * No requiere metadata adicional: el historial de git ya contiene todo. * @@ -54,24 +69,52 @@ const URL_NOISE = /https:\/\/angular\.dev\//; try { const ref = argv.ref ?? 'HEAD'; - const all = (await $`git ls-tree -r --name-only ${ref} -- ${CONTENT_DIR}`).stdout - .trim() - .split('\n') - .filter(Boolean); + const all = await listFiles(ref); const present = new Set(all); const sources = all.filter((f) => f.endsWith('.md') && !f.endsWith('.en.md')); + const originFiles = await listOrigin(); const stale = []; const untranslated = []; + const unprotected = []; + const orphans = []; + const unpaired = []; const skipped = []; let synced = 0; + for (const f of all) { + if (!f.endsWith('.en.md')) continue; + const md = f.replace(/\.en\.md$/, '.md'); + + // Desparejado: un respaldo cuyo `.md` no existe. Casi siempre es un typo en + // el nombre al crearlo, y el efecto es doble y silencioso: la traducción + // queda sin protección frente a `update-origin`, y el respaldo nunca se + // actualiza. Es exactamente lo que pasó con translations-files.en.md. + if (!present.has(md)) { + unpaired.push(f); + continue; + } + + // Huérfano: el original ya no existe upstream, así que `update-origin` no + // volverá a tocar ese `.en.md` nunca. Su blob se compara consigo mismo y el + // archivo se reporta como sincronizado para siempre, mientras la página + // sigue publicada en español pese a haber desaparecido de angular.dev. + if (originFiles) { + const upstream = md.replace(`${CONTENT_DIR}/`, ''); + if (!originFiles.has(upstream)) orphans.push(md); + } + } + for (const md of sources) { const en = md.replace(/\.md$/, '.en.md'); if (!present.has(en)) { - untranslated.push(md); + // Sin `.en.md` hay dos situaciones muy distintas: que el archivo siga en + // inglés (normal), o que ya esté traducido y le falte el respaldo. Lo + // segundo es pérdida de trabajo inminente: el próximo `update-origin` lo + // trata como no traducido y le escribe inglés encima. + (looksSpanish(readFileSync(resolve(ROOT, md), 'utf8')) ? unprotected : untranslated).push(md); continue; } @@ -104,19 +147,83 @@ try { }); } + const payload = { stale, untranslated, unprotected, orphans, unpaired, skipped, synced, + originAvailable: originFiles !== null }; + if (argv.json) { - reportJson({ stale, untranslated, skipped, synced }); + reportJson(payload); } else { - report({ stale, untranslated, skipped, synced, ref }); + report({ ...payload, ref }); } - const blocking = stale.filter((s) => s.prose > 0).length + untranslated.length; + const blocking = + stale.filter((s) => s.prose > 0).length + + untranslated.length + unprotected.length + orphans.length + unpaired.length; process.exit(blocking > 0 ? 1 : 0); } catch (err) { console.error(chalk.red(err)); process.exit(1); } +/** + * Lista los archivos de contenido. + * + * Sobre el árbol de trabajo incluye los NO trackeados, porque justo después de + * `update-origin` las páginas nuevas todavía no están commiteadas: listarlas + * solo con `ls-tree` hacía que el reporte dijera que todo está bien + * precisamente en el momento en que más trabajo pendiente hay. + */ +async function listFiles(ref) { + const split = (s) => s.trim().split('\n').filter(Boolean); + + if (ref !== 'HEAD') { + return split((await $`git ls-tree -r --name-only ${ref} -- ${CONTENT_DIR}`).stdout); + } + + const tracked = split((await $`git ls-files --cached -- ${CONTENT_DIR}`).stdout); + const untracked = split( + (await $`git ls-files --others --exclude-standard -- ${CONTENT_DIR}`).stdout + ); + return [...new Set([...tracked, ...untracked])].sort(); +} + +/** + * Los archivos de contenido del original, para detectar huérfanos. + * + * Devuelve null si el submódulo no está inicializado. Se avisa en el reporte en + * vez de dar el chequeo por bueno: no poder comprobarlo no es lo mismo que no + * tener huérfanos. + */ +async function listOrigin() { + // Se recorre el sistema de archivos, igual que hace `update-origin` al copiar, + // para que ambos vean exactamente el mismo conjunto de páginas. + const dir = resolve(ROOT, 'origin/adev/src/content'); + if (!existsSync(dir)) return null; + + const files = await glob('**/*.md', { cwd: dir }); + return files.length ? new Set(files.filter((f) => !f.endsWith('.en.md'))) : null; +} + +/** + * ¿El texto está en español? Heurística por palabras funcionales, que en prosa + * técnica separa los dos idiomas con holgura. Se exige margen claro para no + * marcar como traducido un archivo inglés que cite algo en español. + */ +function looksSpanish(text) { + const body = text + .replace(/```[\s\S]*?```/g, ' ') + .replace(//g, ' ') + .toLowerCase(); + + const count = (words) => + words.reduce((n, w) => n + (body.match(new RegExp(`\\b${w}\\b`, 'g')) ?? []).length, 0); + + const es = count(['que', 'para', 'los', 'las', 'con', 'una', 'del', 'este', 'cuando', 'puedes', 'debes', 'como']); + const en = count(['the', 'and', 'you', 'this', 'with', 'for', 'that', 'from', 'can', 'your']); + + return es > 5 && es > en * 1.5; +} + /** * El commit desde el que medir: el más reciente que tocó el `.md` Y el `.en.md`. * @@ -177,12 +284,16 @@ function classify(diff) { * Solo incluye los desactualizados con cambios de prosa: los que solo cambiaron * de formato no ameritan abrirle un issue a nadie. */ -function reportJson({ stale, untranslated, skipped, synced }) { +function reportJson({ stale, untranslated, unprotected, orphans, unpaired, skipped, synced, originAvailable }) { console.log( JSON.stringify( { synced, skipped, + originAvailable, + unprotected: unprotected.map((f) => ({ path: f.replace(`${CONTENT_DIR}/`, ''), file: f })), + orphans: orphans.map((f) => ({ path: f.replace(`${CONTENT_DIR}/`, ''), file: f })), + unpaired: unpaired.map((f) => ({ path: f.replace(`${CONTENT_DIR}/`, ''), file: f })), stale: stale .filter((s) => s.prose > 0) .map(({ source, category, since, prose, noise, diff }) => ({ @@ -206,7 +317,7 @@ function reportJson({ stale, untranslated, skipped, synced }) { ); } -function report({ stale, untranslated, skipped, synced, ref }) { +function report({ stale, untranslated, unprotected, orphans, unpaired, skipped, synced, originAvailable, ref }) { const relevant = stale.filter((s) => s.prose > 0); const cosmetic = stale.filter((s) => s.prose === 0); const short = (f) => f.replace(`${CONTENT_DIR}/`, ''); @@ -216,11 +327,44 @@ function report({ stale, untranslated, skipped, synced, ref }) { console.log(` ${chalk.yellow('~')} Solo formato: ${cosmetic.length}`); console.log(` ${chalk.red('✘')} Desactualizadas: ${relevant.length}`); console.log(` ${chalk.red('✘')} Sin traducir: ${untranslated.length}`); - if (skipped.length) { - console.log(` ${chalk.magenta('?')} Sin analizar: ${skipped.length}`); - } + if (unprotected.length) console.log(` ${chalk.red('!')} Sin respaldo: ${unprotected.length}`); + if (unpaired.length) console.log(` ${chalk.red('!')} Desparejadas: ${unpaired.length}`); + if (orphans.length) console.log(` ${chalk.red('✘')} Huérfanas: ${orphans.length}`); + if (skipped.length) console.log(` ${chalk.magenta('?')} Sin analizar: ${skipped.length}`); console.log(); + // Lo más urgente primero: esto es pérdida de trabajo, no deuda pendiente. + if (unprotected.length) { + console.log(chalk.red.bold('Sin respaldo — el próximo update-origin las destruye:\n')); + for (const f of unprotected) { + console.log(` ${short(f)}`); + console.log(chalk.dim(` está en español pero le falta su .en.md`)); + console.log(chalk.dim(` arréglalo: git show :${f} > ${f.replace(/\.md$/, '.en.md')}\n`)); + } + } + + if (unpaired.length) { + console.log(chalk.red.bold('Desparejadas — respaldo sin su traducción:\n')); + for (const f of unpaired) { + console.log(` ${short(f)}`); + console.log(chalk.dim(` no existe ${short(f).replace(/\.en\.md$/, '.md')} — casi siempre es un typo en el nombre`)); + } + console.log(chalk.dim('\n Mientras no emparejen, la traducción queda sin proteger y el respaldo')); + console.log(chalk.dim(' nunca se actualiza.\n')); + } + + if (orphans.length) { + console.log(chalk.red.bold('Huérfanas — ya no existen en el original:\n')); + for (const f of orphans) console.log(` ${short(f)}`); + console.log(chalk.dim('\n Upstream las eliminó o renombró. update-origin nunca borra, así que')); + console.log(chalk.dim(' siguen publicadas en el sitio español y se cuentan como sincronizadas.\n')); + } + + if (!originAvailable) { + console.log(chalk.yellow('No se pudo comprobar si hay páginas huérfanas.')); + console.log(chalk.dim(' El submódulo origin no está inicializado: git submodule update --init\n')); + } + // Nunca en silencio: un archivo que no se pudo analizar no está sincronizado, // y dejarlo caer haría que su issue se cerrara como "ya está al día". if (skipped.length) { From 599763de874607a7addcd0a56564df672c71f44e Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 22:15:15 -0400 Subject: [PATCH 07/28] =?UTF-8?q?fix:=20no=20pedir=20que=20se=20traduzcan?= =?UTF-8?q?=20p=C3=A1ginas=20que=20ya=20no=20existen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La comprobación de huérfanas solo recorría los .en.md, así que una página eliminada upstream que nunca tuvo respaldo caía en "sin traducir". Es decir, el tracking issue le pedía voluntarios a la comunidad para traducir páginas muertas: las 7 guías de pipes y guide/defer.md, todas eliminadas en el original. Ahora la comprobación se hace sobre el .md y antes que nada, tenga respaldo o no. Sin .en.md acababa en "sin traducir"; con .en.md se contaba como sincronizada para siempre, porque update-origin nunca vuelve a tocar un archivo que ya no existe en el original. Efecto sobre el corpus real, con el submódulo inicializado: antes después sincronizadas 345 338 sin traducir 14 7 huérfanas 7 14 Validado contra el PR #192, que hizo esta misma limpieza a mano: las 14 huérfanas que detectamos están entre las 16 que el PR elimina. Las 2 restantes (cli/index.md, router-tutorial.md) siguen existiendo en el origin al SHA que tenemos fijado, así que no son huérfanas todavía: el PR las elimina por reestructuración de v22.1. Dos de las 14 son mudanzas, no borrados: upstream movió typed-forms de reference/migrations a guide/forms, y convirtió tools/devtools.md en una carpeta. Para el detector un renombrado es borrado más alta, que es justo lo que hay que revisar a mano. --- tools/check-translations.mjs | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs index c231be43..7cd4aaf2 100644 --- a/tools/check-translations.mjs +++ b/tools/check-translations.mjs @@ -83,32 +83,28 @@ try { const skipped = []; let synced = 0; + // Desparejado: un respaldo cuyo `.md` no existe. Casi siempre es un typo en el + // nombre al crearlo, y el efecto es doble y silencioso: la traducción queda sin + // protección frente a `update-origin`, y el respaldo nunca se actualiza. Es + // exactamente lo que pasó con translations-files.en.md. for (const f of all) { if (!f.endsWith('.en.md')) continue; - const md = f.replace(/\.en\.md$/, '.md'); - - // Desparejado: un respaldo cuyo `.md` no existe. Casi siempre es un typo en - // el nombre al crearlo, y el efecto es doble y silencioso: la traducción - // queda sin protección frente a `update-origin`, y el respaldo nunca se - // actualiza. Es exactamente lo que pasó con translations-files.en.md. - if (!present.has(md)) { - unpaired.push(f); - continue; - } - - // Huérfano: el original ya no existe upstream, así que `update-origin` no - // volverá a tocar ese `.en.md` nunca. Su blob se compara consigo mismo y el - // archivo se reporta como sincronizado para siempre, mientras la página - // sigue publicada en español pese a haber desaparecido de angular.dev. - if (originFiles) { - const upstream = md.replace(`${CONTENT_DIR}/`, ''); - if (!originFiles.has(upstream)) orphans.push(md); - } + if (!present.has(f.replace(/\.en\.md$/, '.md'))) unpaired.push(f); } for (const md of sources) { const en = md.replace(/\.md$/, '.en.md'); + // Huérfana: la página ya no existe upstream. Se comprueba ANTES que nada, + // tenga respaldo o no: sin `.en.md` acabaría en la lista de "sin traducir", + // pidiéndole a la comunidad que traduzca una página muerta; y con respaldo + // se contaría como sincronizada para siempre, porque `update-origin` nunca + // volverá a tocar un archivo que ya no existe en el original. + if (originFiles && !originFiles.has(md.replace(`${CONTENT_DIR}/`, ''))) { + orphans.push(md); + continue; + } + if (!present.has(en)) { // Sin `.en.md` hay dos situaciones muy distintas: que el archivo siga en // inglés (normal), o que ya esté traducido y le falte el respaldo. Lo From 6a9fa99c3675ae340659c6758a0c55d371b937d7 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 22:22:17 -0400 Subject: [PATCH 08/28] =?UTF-8?q?fix:=20vigilar=20tambi=C3=A9n=20la=20inte?= =?UTF-8?q?rfaz=20del=20sitio,=20no=20solo=20el=20markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update-origin copia 8 objetivos; check-translations solo miraba src/content/**/*.md. Los otros se traducen igual, se sincronizan igual, y nadie comprobaba si se habían desactualizado: core/layout/navigation/navigation.component.en.html core/layout/footer/footer.component.en.html features/home/home.component.en.html routing/sub-navigation-data.en.ts Son el menú, el pie de página, la portada y la estructura de rutas: lo primero que ve cualquier visitante. La causa raíz era que cada herramienta tenía su propia idea de qué está en juego. Ahora la lista vive en tools/lib/targets.mjs y la usan las dos, así que no pueden discrepar. De paso, la manipulación de rutas .en.* pasa a ser genérica por extensión en vez de asumir .md. Los 4 archivos resultaron estar sincronizados, pero eso no se sabía: no se comprobaba. Además, con el submódulo ya disponible pude validar por primera vez los objetivos de copia contra el origin real, y el guard que añadimos en c5bb460 encontró uno roto: navigation-entries/index.ts no existe en el SHA fijado. Lo añadí yo al portar las correcciones de #192, sin caer en que esa ruta llega con v22.1. Queda comentado; #192 lo reintroduce junto al bump, que es donde es válida. Esa validación pasa a ser un test permanente (tools/targets.test.mjs), que se salta solo si el submódulo no está inicializado. Un objetivo desalineado deja de descubrirse a mitad de un sync. --- tools/check-translations.mjs | 72 ++++++++++++++++++++++++++---------- tools/lib/targets.mjs | 50 +++++++++++++++++++++++++ tools/targets.test.mjs | 55 +++++++++++++++++++++++++++ tools/update-origin.mjs | 33 ++--------------- 4 files changed, 161 insertions(+), 49 deletions(-) create mode 100644 tools/lib/targets.mjs create mode 100644 tools/targets.test.mjs diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs index 7cd4aaf2..4077d9bb 100644 --- a/tools/check-translations.mjs +++ b/tools/check-translations.mjs @@ -1,6 +1,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { $, chalk, argv, glob } from 'zx'; +import { copyTargets, enPathOf, isEnFile, sourcePathOf } from './lib/targets.mjs'; const ROOT = resolve(import.meta.dirname, '..'); @@ -41,7 +42,8 @@ const ROOT = resolve(import.meta.dirname, '..'); $.verbose = false; -const CONTENT_DIR = 'adev-es/src/content'; +const ES_DIR = 'adev-es'; +const CONTENT_DIR = `${ES_DIR}/src/content`; /** Agrupa los archivos por sección para que los reportes sean navegables. */ function categorize(path) { @@ -70,9 +72,12 @@ try { const ref = argv.ref ?? 'HEAD'; const all = await listFiles(ref); - const present = new Set(all); - const sources = all.filter((f) => f.endsWith('.md') && !f.endsWith('.en.md')); + + // El alcance sale de los mismos objetivos que copia `update-origin`, para que + // ambas herramientas nunca discrepen sobre qué está en juego. + const inScope = await scopeFromTargets(); + const sources = all.filter((f) => inScope.has(f) && !isEnFile(f)); const originFiles = await listOrigin(); const stale = []; @@ -88,19 +93,19 @@ try { // protección frente a `update-origin`, y el respaldo nunca se actualiza. Es // exactamente lo que pasó con translations-files.en.md. for (const f of all) { - if (!f.endsWith('.en.md')) continue; - if (!present.has(f.replace(/\.en\.md$/, '.md'))) unpaired.push(f); + if (!isEnFile(f)) continue; + if (!present.has(sourcePathOf(f))) unpaired.push(f); } for (const md of sources) { - const en = md.replace(/\.md$/, '.en.md'); + const en = enPathOf(md); // Huérfana: la página ya no existe upstream. Se comprueba ANTES que nada, // tenga respaldo o no: sin `.en.md` acabaría en la lista de "sin traducir", // pidiéndole a la comunidad que traduzca una página muerta; y con respaldo // se contaría como sincronizada para siempre, porque `update-origin` nunca // volverá a tocar un archivo que ya no existe en el original. - if (originFiles && !originFiles.has(md.replace(`${CONTENT_DIR}/`, ''))) { + if (originFiles && !originFiles.has(md.replace(`${ES_DIR}/`, ''))) { orphans.push(md); continue; } @@ -162,7 +167,7 @@ try { } /** - * Lista los archivos de contenido. + * Lista los archivos traducibles de `adev-es`. * * Sobre el árbol de trabajo incluye los NO trackeados, porque justo después de * `update-origin` las páginas nuevas todavía no están commiteadas: listarlas @@ -173,31 +178,60 @@ async function listFiles(ref) { const split = (s) => s.trim().split('\n').filter(Boolean); if (ref !== 'HEAD') { - return split((await $`git ls-tree -r --name-only ${ref} -- ${CONTENT_DIR}`).stdout); + return split((await $`git ls-tree -r --name-only ${ref} -- ${ES_DIR}`).stdout); } - const tracked = split((await $`git ls-files --cached -- ${CONTENT_DIR}`).stdout); - const untracked = split( - (await $`git ls-files --others --exclude-standard -- ${CONTENT_DIR}`).stdout - ); + const tracked = split((await $`git ls-files --cached -- ${ES_DIR}`).stdout); + const untracked = split((await $`git ls-files --others --exclude-standard -- ${ES_DIR}`).stdout); return [...new Set([...tracked, ...untracked])].sort(); } /** - * Los archivos de contenido del original, para detectar huérfanos. + * Los archivos que están en juego, según los mismos objetivos que copia + * `update-origin`. Incluye tanto la traducción como su respaldo. + */ +async function scopeFromTargets() { + const scope = new Set(); + for (const target of copyTargets) { + for (const f of await glob(target, { cwd: resolve(ROOT, ES_DIR) })) { + scope.add(`${ES_DIR}/${f}`); + scope.add(`${ES_DIR}/${enPathOf(f)}`); + } + // Un respaldo cuya traducción ya no existe no lo devuelve el glob del + // objetivo, pero sigue estando en juego: hay que poder marcarlo. + for (const f of await glob(Array.isArray(target) ? target.map(toEn) : toEn(target), { + cwd: resolve(ROOT, ES_DIR), + })) { + scope.add(`${ES_DIR}/${f}`); + } + } + return scope; +} + +/** Convierte un patrón en su equivalente para respaldos: `**\/*.md` → `**\/*.en.md`. */ +function toEn(pattern) { + const neg = pattern.startsWith('!'); + const p = neg ? pattern.slice(1) : pattern; + return (neg ? '!' : '') + p.replace(/(\.[^.\/]+)$/, '.en$1'); +} + +/** + * Los archivos del original, para detectar huérfanos: los que existían en + * `adev-es` pero ya no están upstream. * * Devuelve null si el submódulo no está inicializado. Se avisa en el reporte en * vez de dar el chequeo por bueno: no poder comprobarlo no es lo mismo que no * tener huérfanos. */ async function listOrigin() { - // Se recorre el sistema de archivos, igual que hace `update-origin` al copiar, - // para que ambos vean exactamente el mismo conjunto de páginas. - const dir = resolve(ROOT, 'origin/adev/src/content'); + const dir = resolve(ROOT, 'origin/adev'); if (!existsSync(dir)) return null; - const files = await glob('**/*.md', { cwd: dir }); - return files.length ? new Set(files.filter((f) => !f.endsWith('.en.md'))) : null; + const files = new Set(); + for (const target of copyTargets) { + for (const f of await glob(target, { cwd: dir })) files.add(f); + } + return files.size ? files : null; } /** diff --git a/tools/lib/targets.mjs b/tools/lib/targets.mjs new file mode 100644 index 00000000..7b691e1e --- /dev/null +++ b/tools/lib/targets.mjs @@ -0,0 +1,50 @@ +/** + * Qué se copia desde el original, y por tanto qué se traduce. + * + * Es la fuente única para `update-origin` (que copia) y `check-translations` + * (que vigila). Tenerlo en dos sitios era la causa de que el detector solo + * mirara `src/content`: la navegación, el footer y la portada se traducen + * igual, se sincronizan igual, y nadie comprobaba si se habían desactualizado. + * + * Cada entrada es un objetivo independiente: un patrón suelto, o un grupo + * (patrón + sus exclusiones). Se glob-ea entrada por entrada para poder exigir + * que cada una encuentre al menos un archivo. + */ +export const copyTargets = [ + // Contenido de la documentación + [ + 'src/content/**/*.md', + '!src/content/**/license.md', + // No se traducen: readmes de apps de ejemplo y páginas índice sin prosa. + '!src/content/examples/**/readme.md', + '!src/content/tutorials/README.md', + '!src/content/reference/concepts/overview.md', + ], + // Navegación + 'src/app/routing/sub-navigation-data.ts', + // 'src/app/routing/navigation-entries/index.ts' llega con v22.1; a día de hoy + // no existe en el origin fijado y haría fallar el guard. Lo añade el PR #192 + // junto con el bump, que es donde la ruta es válida. + // Interfaz del sitio + 'src/app/core/constants/links.ts', + 'src/app/core/layout/navigation/navigation.component.html', + 'src/app/core/layout/footer/footer.component.html', + 'src/app/features/home/home.component.html', + 'src/app/features/home/components/**/*.html', +]; + +/** La ruta del respaldo en inglés de un archivo: `x.html` → `x.en.html`. */ +export function enPathOf(file) { + const dot = file.lastIndexOf('.'); + return dot === -1 ? `${file}.en` : `${file.slice(0, dot)}.en${file.slice(dot)}`; +} + +/** ¿Es un respaldo en inglés? Vale para cualquier extensión, no solo `.md`. */ +export function isEnFile(file) { + return /\.en\.[^.]+$/.test(file); +} + +/** De un respaldo a su traducción: `x.en.html` → `x.html`. */ +export function sourcePathOf(enFile) { + return enFile.replace(/\.en(\.[^.]+)$/, '$1'); +} diff --git a/tools/targets.test.mjs b/tools/targets.test.mjs new file mode 100644 index 00000000..11381ab3 --- /dev/null +++ b/tools/targets.test.mjs @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { glob } from 'zx'; +import { copyTargets, enPathOf, isEnFile, sourcePathOf } from './lib/targets.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const ORIGIN = resolve(ROOT, 'origin/adev'); + +test('enPathOf inserta .en antes de la extensión', () => { + assert.equal(enPathOf('src/content/guide/x.md'), 'src/content/guide/x.en.md'); + assert.equal(enPathOf('src/app/footer.component.html'), 'src/app/footer.component.en.html'); + assert.equal(enPathOf('src/app/nav-data.ts'), 'src/app/nav-data.en.ts'); +}); + +test('sourcePathOf revierte enPathOf', () => { + for (const f of ['a/b.md', 'a/b.component.html', 'a/b.ts']) { + assert.equal(sourcePathOf(enPathOf(f)), f); + } +}); + +test('isEnFile reconoce respaldos de cualquier extensión', () => { + assert.ok(isEnFile('x.en.md')); + assert.ok(isEnFile('footer.component.en.html')); + assert.ok(isEnFile('nav.en.ts')); + assert.ok(!isEnFile('x.md')); + assert.ok(!isEnFile('footer.component.html')); +}); + +test('un nombre con .en. intermedio no se confunde con un respaldo', () => { + // `enPathOf` siempre pone `.en` justo antes de la última extensión, así que + // solo esa posición cuenta. + assert.ok(!isEnFile('guide/i18n.en.us/x.md')); +}); + +// Este es el test que importa: valida que lo que declaramos copiar existe de +// verdad. Es la comprobación que habría evitado que los objetivos quedaran +// desalineados durante meses sin que nadie se enterara. +test( + 'todos los objetivos de copia coinciden con archivos del origin', + { skip: !existsSync(ORIGIN) && 'submódulo origin no inicializado' }, + async () => { + const vacios = []; + for (const target of copyTargets) { + const files = await glob(target, { cwd: ORIGIN, caseSensitiveMatch: true }); + if (files.length === 0) vacios.push(target); + } + assert.deepEqual( + vacios, + [], + `objetivos que no coinciden con nada (¿upstream los movió?):\n${vacios.map((t) => ` ${JSON.stringify(t)}`).join('\n')}` + ); + } +); diff --git a/tools/update-origin.mjs b/tools/update-origin.mjs index 4d3988cd..8599ab88 100644 --- a/tools/update-origin.mjs +++ b/tools/update-origin.mjs @@ -1,34 +1,8 @@ import { access, copyFile, mkdir } from 'node:fs/promises'; -import { extname, resolve, dirname } from 'node:path'; +import { resolve, dirname } from 'node:path'; import { $, argv, chalk, glob } from 'zx'; +import { copyTargets, enPathOf } from './lib/targets.mjs'; -/** - * Cada entrada es un objetivo de copia independiente: o un patrón suelto, o un - * grupo (patrón + sus exclusiones). Se glob-ea entrada por entrada para poder - * exigir que cada una encuentre al menos un archivo; si un patrón deja de - * coincidir porque upstream renombró o movió algo, falla en vez de omitirlo en - * silencio. - */ -const copyTargets = [ - // Text contents - [ - 'src/content/**/*.md', - '!src/content/**/license.md', - // No se traducen: readmes de apps de ejemplo y páginas índice sin prosa. - '!src/content/examples/**/readme.md', - '!src/content/tutorials/README.md', - '!src/content/reference/concepts/overview.md', - ], - // Navigation - 'src/app/routing/sub-navigation-data.ts', - 'src/app/routing/navigation-entries/index.ts', - // Others - 'src/app/core/constants/links.ts', - 'src/app/core/layout/navigation/navigation.component.html', - 'src/app/core/layout/footer/footer.component.html', - 'src/app/features/home/home.component.html', - 'src/app/features/home/components/**/*.html', -]; try { console.log(chalk.cyan('Checking adev changes in origin...')); @@ -70,8 +44,7 @@ async function copyOriginFiles() { for (const file of files) { const src = resolve(adevOriginDir, file); - const ext = extname(file); - const enFilePath = file.replace(`${ext}`, `.en${ext}`); + const enFilePath = enPathOf(file); let isTranslated = false; try { From 571271479af597f5e5834c3c8313dab249767ea2 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 22:25:42 -0400 Subject: [PATCH 09/28] =?UTF-8?q?fix:=20l=C3=ADmites=20de=20palabra=20Unic?= =?UTF-8?q?ode=20en=20las=20reglas=20de=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La regla `señal(?!es)` marcaba también señalar, señalización, señalan y Señala: cuatro casos en el corpus, todos español correcto. Hoy solo era ruido en un reporte. Pero la fase 4 del plan quiere --fix, y ahí la regla habría reescrito "señalización" como "signalización" y "señalar" como "signalar", corrompiendo texto correcto en un pase masivo sobre 200 archivos. La causa es sutil y merece quedar escrita: el \b de JavaScript se define sobre [A-Za-z0-9_], así que la ñ y las vocales acentuadas cuentan como separadores y los límites de palabra caen donde no deben. Por eso los patrones usan (? lintText('t.md', text, rules).map((f) => f.expected); +const ruleFor = (expected) => rules.filter((r) => r.expected === expected); + +// --- enmascarado --- + +test('no aplica el glosario dentro de bloques de código', () => { + assert.deepEqual(hits('```ts\nconst librería = 1;\n```'), []); +}); + +test('no aplica dentro de código en línea', () => { + assert.deepEqual(hits('Usa `librería` como nombre.'), []); +}); + +test('no aplica en destinos de enlace', () => { + assert.deepEqual(hits('Ver [guía](/es/librería/overview).'), []); +}); + +test('no aplica dentro de docs-code', () => { + assert.deepEqual(hits('\nlibrería\n'), []); +}); + +test('sí aplica en prosa junto a código en línea', () => { + assert.deepEqual(hits('La librería `@angular/core` es esencial.'), ['biblioteca']); +}); + +test('el enmascarado conserva los números de línea', () => { + const text = 'uno\n```\ndos\ntres\n```\nla librería aquí'; + const [f] = lintText('t.md', text, rules); + assert.equal(f.line, 6); +}); + +// --- el bug de los límites de palabra --- + +test('señal marca la API pero no las palabras que empiezan igual', () => { + const r = ruleFor('signal'); + assert.equal(lintText('t.md', 'La señal se emite.', r).length, 1, 'debe marcar "señal"'); + + for (const palabra of ['señalar', 'señalización', 'señalan', 'Señala', 'señalado']) { + assert.equal( + lintText('t.md', `Esto sirve para ${palabra} el cambio.`, r).length, + 0, + `no debe marcar "${palabra}" — con --fix lo reescribiría mal` + ); + } +}); + +test('señales marca el plural de la API pero no derivados', () => { + const r = ruleFor('signals'); + assert.equal(lintText('t.md', 'Las señales son reactivas.', r).length, 1); + assert.equal(lintText('t.md', 'Hay señales visuales claras.', r).length, 1, 'aquí sí es ambiguo, se marca'); + assert.equal(lintText('t.md', 'La señalización del error.', r).length, 0); +}); + +test('librería no dispara dos veces sobre el plural', () => { + const f = lintText('t.md', 'Usa librerías modernas.', rules); + assert.deepEqual(f.map((x) => x.expected), ['bibliotecas']); +}); + +// --- prefijos de alerta --- + +test('marca los prefijos de alerta traducidos al principio de línea', () => { + assert.deepEqual(hits('NOTA: esto es importante.'), ['NOTE:']); + assert.deepEqual(hits('ÚTIL: un consejo.'), ['HELPFUL:']); +}); + +test('no marca la palabra suelta fuera del prefijo', () => { + assert.deepEqual(hits('Toma nota: esto no es un callout.'), []); +}); + +// --- salud del propio glosario --- + +test('todas las reglas compilan y traen su motivo', () => { + for (const r of rules) { + assert.doesNotThrow(() => new RegExp(r.pattern, 'giu'), `patrón inválido: ${r.pattern}`); + assert.ok(r.expected, `regla sin expected: ${r.pattern}`); + assert.ok(r.reason, `regla sin reason: ${r.pattern}`); + } +}); + +test('ninguna regla marca su propia forma correcta', () => { + // Una regla que coincide con lo que propone haría bucle infinito con --fix. + for (const r of rules) { + const found = lintText('t.md', r.expected, [r]); + assert.equal(found.length, 0, `la regla ${r.pattern} marca su propio expected "${r.expected}"`); + } +}); diff --git a/tools/lib/glossary.mjs b/tools/lib/glossary.mjs new file mode 100644 index 00000000..85d392ba --- /dev/null +++ b/tools/lib/glossary.mjs @@ -0,0 +1,49 @@ +/** + * Lógica pura del linter de terminología, separada del CLI para poder testearla. + */ + +/** + * Reemplaza por espacios las regiones donde el vocabulario español no aplica, + * conservando las posiciones para que los números de línea sigan siendo exactos. + */ +export function mask(text) { + const blank = (m) => m.replace(/[^\n]/g, ' '); + return text + .replace(/```[\s\S]*?```/g, blank) // bloques de código + .replace(//g, blank) // bloques docs-code + .replace(/]*\/>/g, blank) + .replace(/`[^`\n]*`/g, blank) // código en línea + .replace(/\]\([^)\n]*\)/g, blank) // destinos de enlaces + .replace(/\{#[^}\n]*\}/g, blank); // anchors explícitos +} + +/** + * Aplica las reglas del glosario a un texto. + * + * Los patrones se compilan con la bandera `u`, así que pueden usar `\p{L}` para + * delimitar palabras. Hace falta: el `\b` de JavaScript se define sobre + * `[A-Za-z0-9_]`, de modo que `ñ` y las vocales acentuadas cuentan como + * separadores y los límites de palabra caen donde no deben. + */ +export function lintText(file, text, rules) { + const lines = mask(text).split('\n'); + const found = []; + + for (const rule of rules) { + const re = new RegExp(rule.pattern, 'giu'); + lines.forEach((line, i) => { + for (const m of line.matchAll(re)) { + found.push({ + file, + line: i + 1, + col: m.index + 1, + found: m[0], + expected: rule.expected, + reason: rule.reason, + }); + } + }); + } + + return found.sort((a, b) => a.line - b.line); +} diff --git a/tools/lint-glossary.mjs b/tools/lint-glossary.mjs index a9df6c63..5601e221 100644 --- a/tools/lint-glossary.mjs +++ b/tools/lint-glossary.mjs @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { $, argv, chalk, glob, YAML } from 'zx'; +import { lintText } from './lib/glossary.mjs'; /** * Verifica la consistencia terminológica de las traducciones al español. @@ -34,7 +35,7 @@ try { for (const file of files) { const text = await readFile(resolve(ROOT, file), 'utf8'); - findings.push(...lintFile(file, text, rules)); + findings.push(...lintText(file, text, rules)); } if (argv.json) { @@ -48,45 +49,6 @@ try { process.exit(1); } -/** - * Reemplaza por espacios las regiones donde no se debe aplicar el glosario, - * conservando las posiciones para que los números de línea sigan siendo exactos. - */ -function mask(text) { - const blank = (m) => m.replace(/[^\n]/g, ' '); - return text - .replace(/```[\s\S]*?```/g, blank) // bloques de código - .replace(//g, blank) // bloques docs-code - .replace(/]*\/>/g, blank) - .replace(/`[^`\n]*`/g, blank) // código en línea - .replace(/\]\([^)\n]*\)/g, blank) // destinos de enlaces - .replace(/\{#[^}\n]*\}/g, blank); // anchors explícitos -} - -function lintFile(file, text, rules) { - const masked = mask(text); - const lines = masked.split('\n'); - const found = []; - - for (const rule of rules) { - const re = new RegExp(rule.pattern, 'giu'); - lines.forEach((line, i) => { - for (const m of line.matchAll(re)) { - found.push({ - file, - line: i + 1, - col: m.index + 1, - found: m[0], - expected: rule.expected, - reason: rule.reason, - }); - } - }); - } - - return found.sort((a, b) => a.line - b.line); -} - function report(findings, scanned) { if (findings.length === 0) { console.log(chalk.green(`\n✔ Sin problemas de terminología en ${scanned} archivos.\n`)); From 40863d32449f40f7a87be2af7f4756578fcb0dcd Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 22:31:03 -0400 Subject: [PATCH 10/28] =?UTF-8?q?fix:=20comparar=20contra=20el=20=C3=A1rbo?= =?UTF-8?q?l=20de=20trabajo,=20y=20ense=C3=B1ar=20qu=C3=A9=20se=20vigila?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dos problemas que se descubren al preguntarse por el orden de los pasos. 1. El detector leía HEAD, no el árbol de trabajo. update-origin deja los .en.* modificados sin commitear, así que al correr check-translations justo después el reporte decía "todo sincronizado" — en el momento con más trabajo pendiente. Obligaba a un orden no evidente: update, commit, check; y saltarse el commit daba un falso OK en silencio. Sobre HEAD ahora se compara con git hash-object del archivo y el diff se pide sin segundo extremo, así que incluye lo no commiteado. El orden deja de importar. Con --ref se sigue leyendo el commit, que es lo correcto para auditar una rama. 2. El reporte solo listaba problemas, así que era imposible saber qué entraba en el alcance. Cuando la interfaz del sitio estuvo fuera durante meses, la salida se veía idéntica a una correcta. Ahora encabeza con: Vigilando 366 archivos: 361 md · 3 html · 2 ts Si un tipo desaparece de esa línea, se nota. Verificado modificando un .en.html, un .en.ts y un .en.md sin commitear: los tres aparecen ahora como desactualizados y desaparecen al restaurar. --- tools/check-translations.mjs | 41 ++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs index 4077d9bb..d6e42132 100644 --- a/tools/check-translations.mjs +++ b/tools/check-translations.mjs @@ -126,7 +126,16 @@ try { } const before = (await $`git rev-parse ${base.sha}:${en}`.nothrow()).stdout.trim(); - const now = (await $`git rev-parse ${ref}:${en}`.nothrow()).stdout.trim(); + + // Sobre HEAD se compara contra el ÁRBOL DE TRABAJO, no contra el commit. + // `update-origin` deja los `.en.md` modificados sin commitear, y leer HEAD + // hacía que el reporte dijera "todo sincronizado" justo después de traer + // los cambios del original — el momento con más trabajo pendiente. + const live = ref === 'HEAD'; + const now = live + ? (await $`git hash-object ${en}`.nothrow()).stdout.trim() + : (await $`git rev-parse ${ref}:${en}`.nothrow()).stdout.trim(); + if (!before || !now) { skipped.push({ file: md, why: 'no se pudo leer el blob del original' }); continue; @@ -137,7 +146,9 @@ try { continue; } - const diff = (await $`git diff ${base.sha} ${ref} -- ${en}`.nothrow()).stdout; + const diff = live + ? (await $`git diff ${base.sha} -- ${en}`.nothrow()).stdout + : (await $`git diff ${base.sha} ${ref} -- ${en}`.nothrow()).stdout; stale.push({ file: en, source: md, @@ -149,7 +160,7 @@ try { } const payload = { stale, untranslated, unprotected, orphans, unpaired, skipped, synced, - originAvailable: originFiles !== null }; + analyzed: sources, originAvailable: originFiles !== null }; if (argv.json) { reportJson(payload); @@ -314,11 +325,12 @@ function classify(diff) { * Solo incluye los desactualizados con cambios de prosa: los que solo cambiaron * de formato no ameritan abrirle un issue a nadie. */ -function reportJson({ stale, untranslated, unprotected, orphans, unpaired, skipped, synced, originAvailable }) { +function reportJson({ stale, untranslated, unprotected, orphans, unpaired, skipped, synced, analyzed, originAvailable }) { console.log( JSON.stringify( { synced, + analyzed: analyzed.length, skipped, originAvailable, unprotected: unprotected.map((f) => ({ path: f.replace(`${CONTENT_DIR}/`, ''), file: f })), @@ -347,12 +359,27 @@ function reportJson({ stale, untranslated, unprotected, orphans, unpaired, skipp ); } -function report({ stale, untranslated, unprotected, orphans, unpaired, skipped, synced, originAvailable, ref }) { +function report({ stale, untranslated, unprotected, orphans, unpaired, skipped, synced, analyzed, originAvailable, ref }) { const relevant = stale.filter((s) => s.prose > 0); const cosmetic = stale.filter((s) => s.prose === 0); const short = (f) => f.replace(`${CONTENT_DIR}/`, ''); console.log(chalk.cyan(`\nEstado de traducciones · ${ref}\n`)); + + // Se enseña qué se vigiló, no solo qué falló. Si un tipo de archivo deja de + // estar en el alcance —como pasó con la interfaz del sitio durante meses— un + // reporte que solo lista problemas se ve idéntico a uno correcto. + const byExt = {}; + for (const f of analyzed) { + const ext = f.match(/\.([^.]+)$/)?.[1] ?? '?'; + byExt[ext] = (byExt[ext] ?? 0) + 1; + } + const scope = Object.entries(byExt) + .sort((a, b) => b[1] - a[1]) + .map(([ext, n]) => `${n} ${ext}`) + .join(' · '); + console.log(chalk.dim(` Vigilando ${analyzed.length} archivos: ${scope}\n`)); + console.log(` ${chalk.green('✔')} Sincronizadas: ${synced}`); console.log(` ${chalk.yellow('~')} Solo formato: ${cosmetic.length}`); console.log(` ${chalk.red('✘')} Desactualizadas: ${relevant.length}`); @@ -408,7 +435,9 @@ function report({ stale, untranslated, unprotected, orphans, unpaired, skipped, for (const s of relevant) { console.log(` ${chalk.bold(short(s.file))}`); console.log(chalk.dim(` ${s.prose} líneas de prosa, ${s.noise} de formato · desde ${s.since}`)); - console.log(chalk.dim(` git diff ${s.since} ${ref} -- ${s.file}\n`)); + // Sobre HEAD se omite el segundo extremo a propósito, para que el diff + // incluya los cambios del árbol de trabajo que aún no están commiteados. + console.log(chalk.dim(` git diff ${s.since} ${ref === 'HEAD' ? '' : `${ref} `}-- ${s.file}\n`)); } } From 6ee7de4d04814fb69d19245ceee152a6a6173d15 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 22:48:22 -0400 Subject: [PATCH 11/28] feat: marcar todo lo que ya no existe en el original, y abrir su limpieza MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La regla es que un archivo de adev-es solo es válido si traduce algo que existe en origin en la misma ruta, si es su respaldo .en.*, o si es un recurso localizado al que apunte un parche. Todo lo demás sobra. La comprobación de huérfanos solo recorría lo que está en copyTargets, así que veía las páginas pero no los recursos. Ahora recorre todo adev-es, que es la regla completa. Aparece un archivo más: una textura de la portada traducida en abril de 2024 (feat(homepage): translate build for everyone texture) que quedó atrás cuando upstream rediseñó la home. Ya no existe el original ni ninguna referencia; lleva casi dos años sin uso. Para eso hizo falta que listOrigin liste TODOS los archivos del original y no solo los que se copian: la pregunta es "¿esta ruta existe upstream?", y limitarla a los objetivos daba falsos positivos con cualquier archivo que viva fuera de ellos. Por qué importa, más allá del orden: BUILD.bazel compila todo el markdown de src/ con un glob, sin comprobar si hay ruta que lo alcance. Son 82 KB de HTML inalcanzable viajando a Firebase en cada deploy. Y sus .en.* se comparan consigo mismos para siempre, así que 7 de estas páginas se contaban como sincronizadas. Comprobado que no se muestran: el único caso dudoso era tools/devtools, que sí está en el menú español, pero su entrada usa contentPath 'tools/devtools/overview' — resuelve desde la carpeta nueva, no desde nuestro tools/devtools.md. El workflow abre ahora un único issue de limpieza con todos, no uno por archivo: borrarlas es un PR de limpieza, no trabajo repartible. Se actualiza solo y se cierra cuando no queda ninguna. Avisa de que un renombrado se ve igual que un borrado —ruta vieja huérfana más página nueva sin traducir— y que ahí hay que trasladar la traducción, no tirarla. --- .github/scripts/sync-translation-issues.mjs | 89 +++++++++++++++++++++ tools/check-translations.mjs | 62 +++++++++----- 2 files changed, 132 insertions(+), 19 deletions(-) diff --git a/.github/scripts/sync-translation-issues.mjs b/.github/scripts/sync-translation-issues.mjs index 6f4ad8ac..44d098c7 100644 --- a/.github/scripts/sync-translation-issues.mjs +++ b/.github/scripts/sync-translation-issues.mjs @@ -17,6 +17,8 @@ const TRACKING_TITLE = 'Tracking: documentos sin traducir'; const TRACKING_LABELS = ['docs-translation', 'help wanted']; +const CLEANUP_TITLE = 'Limpieza: archivos que ya no existen en el original'; +const CLEANUP_LABELS = ['docs-translation']; const STALE_LABELS = ['docs-translation']; const STALE_PREFIX = 'sync:'; @@ -51,6 +53,93 @@ export default async function syncIssues({ github, context, core, data }) { await syncTracking({ github, core, owner, repo, existing, data }); await syncStale({ github, core, owner, repo, existing, data }); + await syncCleanup({ github, core, owner, repo, existing, data }); +} + +/* ------------------------------------------------------------------ */ +/* Huérfanas: un solo issue de limpieza */ +/* ------------------------------------------------------------------ */ + +/** + * Las páginas huérfanas se borran en lote, no una a una: son un PR de limpieza, + * no unidades de trabajo repartibles. Por eso van a un único issue que se + * actualiza solo y se cierra cuando no queda ninguna. + */ +async function syncCleanup({ github, core, owner, repo, existing, data }) { + const orphans = data.orphans ?? []; + const issue = existing.find((i) => i.title === CLEANUP_TITLE); + + if (!orphans.length) { + if (issue && issue.state === 'open') { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: 'Ya no quedan archivos huérfanos. Cerrando automáticamente.', + }); + await github.rest.issues.update({ + owner, repo, issue_number: issue.number, state: 'closed', state_reason: 'completed', + }); + core.info(`Issue de limpieza cerrado: #${issue.number}`); + } + return; + } + + const body = buildCleanupBody({ orphans, owner, repo }); + + if (!issue) { + const { data: created } = await github.rest.issues.create({ + owner, repo, title: CLEANUP_TITLE, body, labels: CLEANUP_LABELS, + }); + core.info(`Issue de limpieza creado: #${created.number}`); + return; + } + + if (issue.body === body && issue.state === 'open') { + core.info('Issue de limpieza sin cambios.'); + return; + } + + await github.rest.issues.update({ + owner, repo, issue_number: issue.number, body, state: 'open', + }); + core.info(`Issue de limpieza actualizado: #${issue.number}`); +} + +function buildCleanupBody({ orphans, owner, repo }) { + const lines = [ + '## 🧹 Archivos que ya no existen en el original', + '', + 'Upstream los eliminó o los movió. `update-origin` nunca borra, así que siguen aquí.', + '', + '**Por qué importa:** el build compila todo el markdown de `src/` sin comprobar si hay', + 'ruta, así que estas páginas viajan a producción como HTML que nadie puede abrir. Además', + 'sus `.en.*` se comparan consigo mismos para siempre, y por eso cuentan como sincronizadas.', + '', + `**Archivos:** ${orphans.length}`, + '', + ]; + + for (const o of orphans) { + const gh = `https://github.com/${owner}/${repo}/blob/main/${o.file}`; + lines.push(`- [ ] [\`${o.path}\`](${gh})`); + } + + lines.push( + '', + '> [!IMPORTANT]', + '> Antes de borrar, comprueba si upstream lo **movió** en vez de eliminarlo. Un renombrado', + '> se ve igual desde aquí: la ruta vieja queda huérfana y aparece una página nueva sin', + '> traducir. En ese caso hay que trasladar la traducción, no tirarla.', + '', + 'Borra también el `.en.*` correspondiente, si lo tiene.', + '', + '---', + '', + 'Generado por `npm run check-translations`.' + ); + + return lines.join('\n'); } /* ------------------------------------------------------------------ */ diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs index d6e42132..dc7c571e 100644 --- a/tools/check-translations.mjs +++ b/tools/check-translations.mjs @@ -45,6 +45,16 @@ $.verbose = false; const ES_DIR = 'adev-es'; const CONTENT_DIR = `${ES_DIR}/src/content`; +/** + * Ruta corta para los reportes. Los archivos de contenido pierden el prefijo + * entero; el resto conserva `src/`, que basta para distinguirlos de un vistazo. + */ +function short(file) { + return file.startsWith(`${CONTENT_DIR}/`) + ? file.slice(CONTENT_DIR.length + 1) + : file.replace(`${ES_DIR}/`, ''); +} + /** Agrupa los archivos por sección para que los reportes sean navegables. */ function categorize(path) { const p = path.replace(`${CONTENT_DIR}/`, ''); @@ -97,18 +107,32 @@ try { if (!present.has(sourcePathOf(f))) unpaired.push(f); } + // Huérfano: existe en adev-es pero ya no en el original. + // + // Se recorre TODO adev-es, no solo lo que se copia: un archivo puede haber + // entrado legítimamente en su día —como un recurso localizado— y quedarse + // atrás cuando upstream rediseñó. El build lo compila igual (BUILD.bazel usa + // glob sobre src/**), así que viaja a producción como HTML inalcanzable. + // + // Los respaldos .en.* se saltan a propósito: no existen upstream por diseño. + // Cuando su traducción es huérfana, se borran junto a ella. + const orphanSet = new Set(); + if (originFiles) { + for (const f of all) { + if (isEnFile(f)) continue; + if (!originFiles.has(f.replace(`${ES_DIR}/`, ''))) { + orphans.push(f); + orphanSet.add(f); + } + } + } + for (const md of sources) { const en = enPathOf(md); - // Huérfana: la página ya no existe upstream. Se comprueba ANTES que nada, - // tenga respaldo o no: sin `.en.md` acabaría en la lista de "sin traducir", - // pidiéndole a la comunidad que traduzca una página muerta; y con respaldo - // se contaría como sincronizada para siempre, porque `update-origin` nunca - // volverá a tocar un archivo que ya no existe en el original. - if (originFiles && !originFiles.has(md.replace(`${ES_DIR}/`, ''))) { - orphans.push(md); - continue; - } + // Ya contabilizada como huérfana arriba. Sin este corte acabaría además en + // "sin traducir", pidiéndole a la comunidad que traduzca una página muerta. + if (orphanSet.has(md)) continue; if (!present.has(en)) { // Sin `.en.md` hay dos situaciones muy distintas: que el archivo siga en @@ -238,11 +262,12 @@ async function listOrigin() { const dir = resolve(ROOT, 'origin/adev'); if (!existsSync(dir)) return null; - const files = new Set(); - for (const target of copyTargets) { - for (const f of await glob(target, { cwd: dir })) files.add(f); - } - return files.size ? files : null; + // Se listan TODOS los archivos del original, no solo los que se copian. La + // pregunta que hay que poder responder es "¿esta ruta existe upstream?", y + // limitarla a los objetivos de copia daría falsos positivos con cualquier + // archivo de adev-es que viva fuera de ellos. + const files = await glob('**/*', { cwd: dir, onlyFiles: true }); + return files.length ? new Set(files) : null; } /** @@ -333,9 +358,9 @@ function reportJson({ stale, untranslated, unprotected, orphans, unpaired, skipp analyzed: analyzed.length, skipped, originAvailable, - unprotected: unprotected.map((f) => ({ path: f.replace(`${CONTENT_DIR}/`, ''), file: f })), - orphans: orphans.map((f) => ({ path: f.replace(`${CONTENT_DIR}/`, ''), file: f })), - unpaired: unpaired.map((f) => ({ path: f.replace(`${CONTENT_DIR}/`, ''), file: f })), + unprotected: unprotected.map((f) => ({ path: short(f), file: f })), + orphans: orphans.map((f) => ({ path: short(f), file: f })), + unpaired: unpaired.map((f) => ({ path: short(f), file: f })), stale: stale .filter((s) => s.prose > 0) .map(({ source, category, since, prose, noise, diff }) => ({ @@ -348,7 +373,7 @@ function reportJson({ stale, untranslated, unprotected, orphans, unpaired, skipp diff, })), untranslated: untranslated.map((f) => ({ - path: f.replace(`${CONTENT_DIR}/`, ''), + path: short(f), file: f, category: categorize(f), })), @@ -362,7 +387,6 @@ function reportJson({ stale, untranslated, unprotected, orphans, unpaired, skipp function report({ stale, untranslated, unprotected, orphans, unpaired, skipped, synced, analyzed, originAvailable, ref }) { const relevant = stale.filter((s) => s.prose > 0); const cosmetic = stale.filter((s) => s.prose === 0); - const short = (f) => f.replace(`${CONTENT_DIR}/`, ''); console.log(chalk.cyan(`\nEstado de traducciones · ${ref}\n`)); From 787a543ee37247f94877bd59ac62ebc68b6045fc Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 23:03:28 -0400 Subject: [PATCH 12/28] =?UTF-8?q?fix:=20auditar=20una=20rama=20con=20su=20?= =?UTF-8?q?propio=20alcance=20y=20sin=20mentir=20sobre=20hu=C3=A9rfanas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditar el PR #192 destapó dos bugs del modo --ref, ambos introducidos al generalizar el alcance. 1. El alcance salía del disco, no de la rama. scopeFromTargets glob-eaba el árbol de trabajo, así que los archivos que la rama AÑADE quedaban fuera: contaba 350 archivos y 6 sin traducir cuando son 388 y 38. Justo las páginas nuevas de v22.1, que es lo que más interesa de un PR de bump. Ahora el alcance se decide con matchesTarget, que trabaja sobre cadenas y no toca el disco, aplicado a la lista de archivos de la referencia. 2. Las huérfanas se calculaban contra el origin montado, que corresponde a HEAD. El PR sube de v21.0.1 a v22.1, así que todo lo que esa versión añadió aparecía como huérfano: 39 inventadas. Ahora, si la referencia apunta a otro origin, el chequeo no se hace y se dice por qué. Mejor no responder que responder mal — es el mismo criterio que con el submódulo sin inicializar. También, la detección de idioma leía del disco; en una rama esos archivos no están en el árbol de trabajo y reventaba con ENOENT en ai/agent-skills.md. Ahora lee el contenido de la referencia. matchesTarget entiende **, * y la negación con !, y una exclusión manda sobre cualquier inclusión. Reemplaza al glob de disco también para HEAD, así que ambos modos comparten exactamente la misma definición de alcance. --- tools/check-translations.mjs | 74 +++++++++++++++++------------------- tools/lib/targets.mjs | 38 ++++++++++++++++++ 2 files changed, 73 insertions(+), 39 deletions(-) diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs index dc7c571e..6fcbf804 100644 --- a/tools/check-translations.mjs +++ b/tools/check-translations.mjs @@ -1,7 +1,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { $, chalk, argv, glob } from 'zx'; -import { copyTargets, enPathOf, isEnFile, sourcePathOf } from './lib/targets.mjs'; +import { copyTargets, enPathOf, isEnFile, matchesTarget, sourcePathOf } from './lib/targets.mjs'; const ROOT = resolve(import.meta.dirname, '..'); @@ -85,10 +85,13 @@ try { const present = new Set(all); // El alcance sale de los mismos objetivos que copia `update-origin`, para que - // ambas herramientas nunca discrepen sobre qué está en juego. - const inScope = await scopeFromTargets(); - const sources = all.filter((f) => inScope.has(f) && !isEnFile(f)); - const originFiles = await listOrigin(); + // ambas herramientas nunca discrepen sobre qué está en juego. Se calcula sobre + // la lista de archivos de la referencia, no sobre el disco: al auditar una + // rama, sus archivos nuevos tienen que contar. + const sources = all.filter( + (f) => !isEnFile(f) && copyTargets.some((t) => matchesTarget(f.replace(`${ES_DIR}/`, ''), t)) + ); + const originFiles = await listOrigin(ref); const stale = []; const untranslated = []; @@ -127,6 +130,8 @@ try { } } + const live = ref === 'HEAD'; + for (const md of sources) { const en = enPathOf(md); @@ -139,7 +144,12 @@ try { // inglés (normal), o que ya esté traducido y le falte el respaldo. Lo // segundo es pérdida de trabajo inminente: el próximo `update-origin` lo // trata como no traducido y le escribe inglés encima. - (looksSpanish(readFileSync(resolve(ROOT, md), 'utf8')) ? unprotected : untranslated).push(md); + // El contenido se lee de la referencia auditada, no del disco: al mirar + // una rama, sus archivos nuevos no están en el árbol de trabajo. + const text = live + ? readFileSync(resolve(ROOT, md), 'utf8') + : (await $`git show ${ref}:${md}`.nothrow()).stdout; + (looksSpanish(text) ? unprotected : untranslated).push(md); continue; } @@ -155,7 +165,6 @@ try { // `update-origin` deja los `.en.md` modificados sin commitear, y leer HEAD // hacía que el reporte dijera "todo sincronizado" justo después de traer // los cambios del original — el momento con más trabajo pendiente. - const live = ref === 'HEAD'; const now = live ? (await $`git hash-object ${en}`.nothrow()).stdout.trim() : (await $`git rev-parse ${ref}:${en}`.nothrow()).stdout.trim(); @@ -221,35 +230,6 @@ async function listFiles(ref) { return [...new Set([...tracked, ...untracked])].sort(); } -/** - * Los archivos que están en juego, según los mismos objetivos que copia - * `update-origin`. Incluye tanto la traducción como su respaldo. - */ -async function scopeFromTargets() { - const scope = new Set(); - for (const target of copyTargets) { - for (const f of await glob(target, { cwd: resolve(ROOT, ES_DIR) })) { - scope.add(`${ES_DIR}/${f}`); - scope.add(`${ES_DIR}/${enPathOf(f)}`); - } - // Un respaldo cuya traducción ya no existe no lo devuelve el glob del - // objetivo, pero sigue estando en juego: hay que poder marcarlo. - for (const f of await glob(Array.isArray(target) ? target.map(toEn) : toEn(target), { - cwd: resolve(ROOT, ES_DIR), - })) { - scope.add(`${ES_DIR}/${f}`); - } - } - return scope; -} - -/** Convierte un patrón en su equivalente para respaldos: `**\/*.md` → `**\/*.en.md`. */ -function toEn(pattern) { - const neg = pattern.startsWith('!'); - const p = neg ? pattern.slice(1) : pattern; - return (neg ? '!' : '') + p.replace(/(\.[^.\/]+)$/, '.en$1'); -} - /** * Los archivos del original, para detectar huérfanos: los que existían en * `adev-es` pero ya no están upstream. @@ -258,10 +238,20 @@ function toEn(pattern) { * vez de dar el chequeo por bueno: no poder comprobarlo no es lo mismo que no * tener huérfanos. */ -async function listOrigin() { +async function listOrigin(ref) { const dir = resolve(ROOT, 'origin/adev'); if (!existsSync(dir)) return null; + // El submódulo montado corresponde a HEAD. Si se audita otra referencia que + // apunta a un origin distinto —un PR que sube de versión, por ejemplo—, + // comparar contra el que está en disco marcaría como huérfano todo lo que esa + // versión añadió. Mejor no responder que responder mal. + if (ref !== 'HEAD') { + const here = (await $`git rev-parse HEAD:origin`.nothrow()).stdout.trim(); + const there = (await $`git rev-parse ${ref}:origin`.nothrow()).stdout.trim(); + if (here && there && here !== there) return null; + } + // Se listan TODOS los archivos del original, no solo los que se copian. La // pregunta que hay que poder responder es "¿esta ruta existe upstream?", y // limitarla a los objetivos de copia daría falsos positivos con cualquier @@ -442,8 +432,14 @@ function report({ stale, untranslated, unprotected, orphans, unpaired, skipped, } if (!originAvailable) { - console.log(chalk.yellow('No se pudo comprobar si hay páginas huérfanas.')); - console.log(chalk.dim(' El submódulo origin no está inicializado: git submodule update --init\n')); + console.log(chalk.yellow('No se comprobó si hay archivos huérfanos.')); + console.log( + chalk.dim( + ref === 'HEAD' + ? ' El submódulo origin no está inicializado: git submodule update --init\n' + : ` ${ref} apunta a otra versión del original que la que hay en disco.\n` + ) + ); } // Nunca en silencio: un archivo que no se pudo analizar no está sincronizado, diff --git a/tools/lib/targets.mjs b/tools/lib/targets.mjs index 7b691e1e..c07798cf 100644 --- a/tools/lib/targets.mjs +++ b/tools/lib/targets.mjs @@ -33,6 +33,44 @@ export const copyTargets = [ 'src/app/features/home/components/**/*.html', ]; +/** + * ¿La ruta cae dentro de un objetivo? Trabaja sobre cadenas, no sobre el disco, + * para poder calcular el alcance de una rama cualquiera —un PR, por ejemplo— + * sin tener que sacar sus archivos a un directorio. + * + * Soporta lo que usan los objetivos: `**`, `*` y la negación con `!`. + */ +export function matchesTarget(file, target) { + const patterns = Array.isArray(target) ? target : [target]; + let hit = false; + + for (const p of patterns) { + const negated = p.startsWith('!'); + if (globToRegExp(negated ? p.slice(1) : p).test(file)) { + if (negated) return false; // una exclusión manda sobre cualquier inclusión + hit = true; + } + } + + return hit; +} + +function globToRegExp(pattern) { + const ANY_SEGMENTS = '\u0000'; // marcador para `**/` + const ANY_CHARS = '\u0001'; // marcador para `**` suelto + + const rx = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*\//g, ANY_SEGMENTS) + .replace(/\*\*/g, ANY_CHARS) + .replace(/\*/g, '[^/]*') + .replaceAll(ANY_SEGMENTS, '(?:[^/]+/)*') + .replaceAll(ANY_CHARS, '.*'); + + return new RegExp(`^${rx}$`); +} + + /** La ruta del respaldo en inglés de un archivo: `x.html` → `x.en.html`. */ export function enPathOf(file) { const dot = file.lastIndexOf('.'); From e254c7dd7a94418a4e53bd895fceb5c3bc202b73 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 23:10:57 -0400 Subject: [PATCH 13/28] =?UTF-8?q?fix:=20el=20sync=20de=20issues=20confund?= =?UTF-8?q?=C3=ADa=20pull=20requests=20con=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit github.rest.issues.listForRepo devuelve TAMBIÉN los pull requests, y este repo tiene PRs abiertos titulados «translate: …» con la etiqueta docs-translation: #159, #101, #98, #97, #96. Dos consecuencias, la segunda destructiva: - syncTracking los tomaba por declaraciones de trabajo y marcaba archivos como reclamados por un PR que no los reclama. - syncStale cierra los issues que ya no aplican. Cerrar un PR por esa vía lo cierra de verdad, y el bot habría ido cerrando pull requests de contribuidores. Se filtran con !i.pull_request. Además, para poder probar el workflow sin arriesgar el repo: - Modo simulación. El workflow_dispatch trae un input dry_run que viene activado por defecto, así que la primera ejecución manual registra qué haría y no escribe nada. Hay que desmarcarlo a propósito para que actúe. - Tests del script, 9 casos sobre un repo de mentira: creación, idempotencia, cierre al resolverse, reclamo por un contribuidor, el recorte del diff bajo el límite de GitHub, la simulación, y los dos casos de pull requests. Comprobado que fallan si se quita el filtro. npm test cubre ahora también .github/scripts: 47 tests. --- .github/scripts/sync-translation-issues.mjs | 38 ++++- .../scripts/sync-translation-issues.test.mjs | 149 ++++++++++++++++++ .github/workflows/sync-translation-issues.yml | 9 +- package.json | 2 +- 4 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/sync-translation-issues.test.mjs diff --git a/.github/scripts/sync-translation-issues.mjs b/.github/scripts/sync-translation-issues.mjs index 44d098c7..784a71b0 100644 --- a/.github/scripts/sync-translation-issues.mjs +++ b/.github/scripts/sync-translation-issues.mjs @@ -40,10 +40,18 @@ const CATEGORY_ORDER = Object.keys(CATEGORY_NAMES); // GitHub rechaza cuerpos de issue por encima de ~65k caracteres. const MAX_DIFF_CHARS = 12000; -export default async function syncIssues({ github, context, core, data }) { +export default async function syncIssues({ github, context, core, data, dryRun = false }) { const { owner, repo } = context.repo; - const existing = await github.paginate(github.rest.issues.listForRepo, { + // En simulación se registra lo que se haría y no se escribe nada. Permite + // lanzar el workflow contra el repo real sin arriesgarse a llenar de issues + // por un fallo tonto de configuración. + if (dryRun) { + core.info('SIMULACIÓN: no se creará ni modificará ningún issue.'); + github = wrapDryRun(github, core); + } + + const listed = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'all', @@ -51,11 +59,37 @@ export default async function syncIssues({ github, context, core, data }) { per_page: 100, }); + // El endpoint de issues devuelve TAMBIÉN los pull requests, y este repo tiene + // PRs titulados «translate: …» con esta misma etiqueta. Sin filtrarlos, un PR + // se tomaría por una declaración de trabajo; y peor, syncStale cierra lo que + // ya no aplica, así que cerraría el PR de verdad. + const existing = listed.filter((i) => !i.pull_request); + await syncTracking({ github, core, owner, repo, existing, data }); await syncStale({ github, core, owner, repo, existing, data }); await syncCleanup({ github, core, owner, repo, existing, data }); } +/** Intercepta las escrituras y las registra en vez de ejecutarlas. */ +function wrapDryRun(github, core) { + const log = (verb) => async (params) => { + const what = params.title ?? `#${params.issue_number}`; + core.info(` [simulado] ${verb} ${what}${params.state ? ` → ${params.state}` : ''}`); + return { data: { number: 0 } }; + }; + return { + paginate: github.paginate.bind(github), + rest: { + issues: { + listForRepo: github.rest.issues.listForRepo, + create: log('crear'), + update: log('actualizar'), + createComment: log('comentar'), + }, + }, + }; +} + /* ------------------------------------------------------------------ */ /* Huérfanas: un solo issue de limpieza */ /* ------------------------------------------------------------------ */ diff --git a/.github/scripts/sync-translation-issues.test.mjs b/.github/scripts/sync-translation-issues.test.mjs new file mode 100644 index 00000000..99c84306 --- /dev/null +++ b/.github/scripts/sync-translation-issues.test.mjs @@ -0,0 +1,149 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import syncIssues from './sync-translation-issues.mjs'; + +const context = { repo: { owner: 'angular-hispano', repo: 'angular-docs-es' } }; + +/** Repo de mentira: guarda los issues y registra cada llamada de escritura. */ +function fakeRepo(seed = []) { + const store = seed.map((i) => ({ ...i })); + const actions = []; + let next = 900; + + const github = { + paginate: async () => store, + rest: { + issues: { + listForRepo: 'listForRepo', + create: async ({ title, body, labels }) => { + const number = next++; + actions.push({ op: 'create', title, labels }); + store.push({ number, title, body, state: 'open' }); + return { data: { number } }; + }, + update: async ({ issue_number, body, state }) => { + const i = store.find((x) => x.number === issue_number); + actions.push({ op: 'update', number: issue_number, state }); + if (body !== undefined) i.body = body; + if (state) i.state = state; + return { data: i }; + }, + createComment: async ({ issue_number }) => { + actions.push({ op: 'comment', number: issue_number }); + }, + }, + }, + }; + + return { github, store, actions, core: { info() {} } }; +} + +const datos = (over = {}) => ({ + synced: 300, + stale: [{ path: 'guide/a.md', file: 'adev-es/src/content/guide/a.md', category: 'guide', + since: 'abc1234', prose: 3, noise: 0, diff: '@@ -1 +1 @@\n-old\n+new' }], + untranslated: [{ path: 'guide/b.md', file: 'adev-es/src/content/guide/b.md', category: 'guide' }], + orphans: [{ path: 'guide/c.md', file: 'adev-es/src/content/guide/c.md' }], + ...over, +}); + +test('crea el tracking, el issue de cada desactualizado y el de limpieza', async () => { + const r = fakeRepo(); + await syncIssues({ ...r, context, data: datos() }); + + const creados = r.actions.filter((a) => a.op === 'create').map((a) => a.title); + assert.deepEqual(creados, [ + 'Tracking: documentos sin traducir', + 'sync: guide/a.md', + 'Limpieza: archivos que ya no existen en el original', + ]); +}); + +test('es idempotente: una segunda pasada no hace nada', async () => { + const r = fakeRepo(); + await syncIssues({ ...r, context, data: datos() }); + r.actions.length = 0; + + await syncIssues({ ...r, context, data: datos() }); + assert.deepEqual(r.actions, [], 'no debería tocar nada'); +}); + +test('cierra el issue cuando la traducción se pone al día', async () => { + const r = fakeRepo(); + await syncIssues({ ...r, context, data: datos() }); + r.actions.length = 0; + + await syncIssues({ ...r, context, data: datos({ stale: [] }) }); + + const issue = r.store.find((i) => i.title === 'sync: guide/a.md'); + assert.equal(issue.state, 'closed'); + assert.ok(r.actions.some((a) => a.op === 'comment' && a.number === issue.number)); +}); + +test('cierra la limpieza cuando ya no hay huérfanos', async () => { + const r = fakeRepo(); + await syncIssues({ ...r, context, data: datos() }); + + await syncIssues({ ...r, context, data: datos({ orphans: [] }) }); + assert.equal(r.store.find((i) => i.title.startsWith('Limpieza')).state, 'closed'); +}); + +test('marca como reclamado el archivo que alguien declaró', async () => { + const r = fakeRepo([ + { number: 500, title: 'translate: guide/b.md', body: 'me encargo', state: 'open' }, + ]); + await syncIssues({ ...r, context, data: datos() }); + + const tracking = r.store.find((i) => i.title.startsWith('Tracking')); + assert.match(tracking.body, /- \[x\] `guide\/b\.md`.*#500/); +}); + +// Este es el que importa: el endpoint de issues devuelve también los PRs, y el +// repo tiene pull requests titulados «translate: …» con la misma etiqueta. +test('ignora los pull requests, no solo los issues', async () => { + const r = fakeRepo([ + { number: 101, title: 'translate: guide/b.md', body: '', state: 'open', + pull_request: { url: 'https://api.github.com/…' } }, + ]); + await syncIssues({ ...r, context, data: datos() }); + + const tracking = r.store.find((i) => i.title.startsWith('Tracking')); + assert.match(tracking.body, /- \[ \] `guide\/b\.md`/, 'un PR no reclama el archivo'); +}); + +test('nunca cierra un pull request', async () => { + const r = fakeRepo([ + { number: 102, title: 'sync: guide/z.md', body: '', state: 'open', + pull_request: { url: 'https://api.github.com/…' } }, + ]); + await syncIssues({ ...r, context, data: datos({ stale: [] }) }); + + assert.ok( + !r.actions.some((a) => a.op === 'update' && a.number === 102), + 'cerrar por esta vía cerraría el PR de verdad' + ); +}); + +test('en simulación no escribe nada', async () => { + const r = fakeRepo(); + await syncIssues({ ...r, context, data: datos(), dryRun: true }); + + assert.deepEqual(r.actions, []); + assert.deepEqual(r.store, []); +}); + +test('el diff se recorta por debajo del límite de GitHub', async () => { + const r = fakeRepo(); + await syncIssues({ + ...r, + context, + data: datos({ + stale: [{ path: 'guide/a.md', file: 'adev-es/src/content/guide/a.md', category: 'guide', + since: 'abc1234', prose: 5, noise: 0, diff: 'x'.repeat(80_000) }], + }), + }); + + const issue = r.store.find((i) => i.title === 'sync: guide/a.md'); + assert.ok(issue.body.length < 65_000, `cuerpo de ${issue.body.length} caracteres`); + assert.match(issue.body, /Diff recortado/); +}); diff --git a/.github/workflows/sync-translation-issues.yml b/.github/workflows/sync-translation-issues.yml index 626a3353..c92f0333 100644 --- a/.github/workflows/sync-translation-issues.yml +++ b/.github/workflows/sync-translation-issues.yml @@ -9,6 +9,11 @@ on: issues: types: [opened, closed, reopened, labeled] workflow_dispatch: + inputs: + dry_run: + description: 'Simular: registrar qué issues se crearían, sin escribir nada' + type: boolean + default: true permissions: contents: read @@ -53,4 +58,6 @@ jobs: const { readFileSync } = require('node:fs'); const { default: syncIssues } = await import('${{ github.workspace }}/.github/scripts/sync-translation-issues.mjs'); const data = JSON.parse(readFileSync('/tmp/status.json', 'utf8')); - await syncIssues({ github, context, core, data }); + const dryRun = context.eventName === 'workflow_dispatch' + && context.payload.inputs?.dry_run !== 'false'; + await syncIssues({ github, context, core, data, dryRun }); diff --git a/package.json b/package.json index 790dc34d..ca04bb8e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": "node --test tools/*.test.mjs", + "test": "node --test tools/*.test.mjs .github/scripts/*.test.mjs", "build": "zx tools/build.mjs", "start": "zx tools/watch.mjs", "update-origin": "zx tools/update-origin.mjs", From 367f90e8478898eef8c684f6a3f0402d7aa17d1c Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Mon, 17 Aug 2026 23:18:15 -0400 Subject: [PATCH 14/28] =?UTF-8?q?revert:=20no=20automatizar=20la=20creaci?= =?UTF-8?q?=C3=B3n=20de=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El repo ya la hace a mano, y mejor: #185-#190 agrupan las páginas nuevas de v22.1 por sección, con títulos en español y prefijo de versión. Entre esos seis issues y #197 cubren 34 de los 38 archivos que detectamos. Un tracking issue generado habría duplicado ese trabajo y competido como segunda fuente de verdad. Y como esos issues no llevan la etiqueta docs-translation, el bot ni los habría visto: habría creado duplicados sin enterarse. Agrupar por sección es una decisión editorial —qué va junto, cómo se llama, qué versión lo trae— y un script no la acierta. Se elimina el workflow, su script, sus tests y la plantilla de issue. La detección se queda: es lo que produce la lista con la que se componen esos issues a mano. Queda en el historial por si alguna vez se quiere recuperar. UPDATE-ORIGIN.md pasa a documentar el flujo manual, y de paso corrige la descripción del detector, que seguía hablando de dos estados cuando ya distingue cinco. --- .../ISSUE_TEMPLATE/translation-checkout.md | 39 -- .github/scripts/sync-translation-issues.mjs | 380 ------------------ .../scripts/sync-translation-issues.test.mjs | 149 ------- .github/workflows/sync-translation-issues.yml | 63 --- UPDATE-ORIGIN.md | 31 +- package.json | 2 +- 6 files changed, 24 insertions(+), 640 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/translation-checkout.md delete mode 100644 .github/scripts/sync-translation-issues.mjs delete mode 100644 .github/scripts/sync-translation-issues.test.mjs delete mode 100644 .github/workflows/sync-translation-issues.yml diff --git a/.github/ISSUE_TEMPLATE/translation-checkout.md b/.github/ISSUE_TEMPLATE/translation-checkout.md deleted file mode 100644 index 2fc7e6f1..00000000 --- a/.github/ISSUE_TEMPLATE/translation-checkout.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: Declarar una traducción -about: Avisa que vas a traducir un documento, para que nadie más lo tome -title: 'translate: ' -labels: docs-translation ---- - - - -## Archivo - -`` - -## Antes de empezar - -- [ ] Leí la [guía de contribución](../../CONTRIBUTING.md) -- [ ] Revisé el glosario de términos en [`glosario.yml`](../../glosario.yml) - -## Cómo traducir - -```shell -# 1. Respalda el original en inglés (esto es lo que protege tu traducción -# de ser sobrescrita en la próxima sincronización) -cp adev-es/src/content/.md adev-es/src/content/.en.md - -# 2. Traduce el archivo .md - -# 3. Verifica antes de abrir el PR -npm run lint-glossary -npm run check-translations -``` - -Al mergearse el PR, este issue y el de tracking se actualizan solos. diff --git a/.github/scripts/sync-translation-issues.mjs b/.github/scripts/sync-translation-issues.mjs deleted file mode 100644 index 784a71b0..00000000 --- a/.github/scripts/sync-translation-issues.mjs +++ /dev/null @@ -1,380 +0,0 @@ -/** - * Convierte la salida de `check-translations --json` en issues de GitHub. - * - * Usa un modelo híbrido, según la naturaleza de cada problema: - * - * - SIN TRADUCIR → un único issue de tracking con checkboxes. Son muchos y de - * baja rotación; abrir un issue por cada uno sería ruido. Cada entrada trae un - * link que pre-rellena un issue de "declaración" para que alguien lo reclame - * solo cuando de verdad vaya a trabajarlo. - * - * - DESACTUALIZADOS → un issue individual por archivo. Son pocos y cada uno - * viene con un diff concreto y accionable, así que se pueden asignar y cerrar - * como unidades de trabajo reales. - * - * Es idempotente: se puede correr en cada push a main sin duplicar nada. - */ - -const TRACKING_TITLE = 'Tracking: documentos sin traducir'; -const TRACKING_LABELS = ['docs-translation', 'help wanted']; -const CLEANUP_TITLE = 'Limpieza: archivos que ya no existen en el original'; -const CLEANUP_LABELS = ['docs-translation']; -const STALE_LABELS = ['docs-translation']; -const STALE_PREFIX = 'sync:'; - -const CATEGORY_NAMES = { - guide: '📖 Guías', - tutorials: '🎓 Tutoriales', - reference: '📚 Referencia', - 'best-practices': '⚡ Mejores prácticas', - tools: '🛠️ Herramientas', - ecosystem: '🌐 Ecosistema', - ai: '🤖 IA', - cli: '🔧 CLI', - examples: '📦 Ejemplos', - other: '📄 Otros', -}; - -const CATEGORY_ORDER = Object.keys(CATEGORY_NAMES); - -// GitHub rechaza cuerpos de issue por encima de ~65k caracteres. -const MAX_DIFF_CHARS = 12000; - -export default async function syncIssues({ github, context, core, data, dryRun = false }) { - const { owner, repo } = context.repo; - - // En simulación se registra lo que se haría y no se escribe nada. Permite - // lanzar el workflow contra el repo real sin arriesgarse a llenar de issues - // por un fallo tonto de configuración. - if (dryRun) { - core.info('SIMULACIÓN: no se creará ni modificará ningún issue.'); - github = wrapDryRun(github, core); - } - - const listed = await github.paginate(github.rest.issues.listForRepo, { - owner, - repo, - state: 'all', - labels: 'docs-translation', - per_page: 100, - }); - - // El endpoint de issues devuelve TAMBIÉN los pull requests, y este repo tiene - // PRs titulados «translate: …» con esta misma etiqueta. Sin filtrarlos, un PR - // se tomaría por una declaración de trabajo; y peor, syncStale cierra lo que - // ya no aplica, así que cerraría el PR de verdad. - const existing = listed.filter((i) => !i.pull_request); - - await syncTracking({ github, core, owner, repo, existing, data }); - await syncStale({ github, core, owner, repo, existing, data }); - await syncCleanup({ github, core, owner, repo, existing, data }); -} - -/** Intercepta las escrituras y las registra en vez de ejecutarlas. */ -function wrapDryRun(github, core) { - const log = (verb) => async (params) => { - const what = params.title ?? `#${params.issue_number}`; - core.info(` [simulado] ${verb} ${what}${params.state ? ` → ${params.state}` : ''}`); - return { data: { number: 0 } }; - }; - return { - paginate: github.paginate.bind(github), - rest: { - issues: { - listForRepo: github.rest.issues.listForRepo, - create: log('crear'), - update: log('actualizar'), - createComment: log('comentar'), - }, - }, - }; -} - -/* ------------------------------------------------------------------ */ -/* Huérfanas: un solo issue de limpieza */ -/* ------------------------------------------------------------------ */ - -/** - * Las páginas huérfanas se borran en lote, no una a una: son un PR de limpieza, - * no unidades de trabajo repartibles. Por eso van a un único issue que se - * actualiza solo y se cierra cuando no queda ninguna. - */ -async function syncCleanup({ github, core, owner, repo, existing, data }) { - const orphans = data.orphans ?? []; - const issue = existing.find((i) => i.title === CLEANUP_TITLE); - - if (!orphans.length) { - if (issue && issue.state === 'open') { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issue.number, - body: 'Ya no quedan archivos huérfanos. Cerrando automáticamente.', - }); - await github.rest.issues.update({ - owner, repo, issue_number: issue.number, state: 'closed', state_reason: 'completed', - }); - core.info(`Issue de limpieza cerrado: #${issue.number}`); - } - return; - } - - const body = buildCleanupBody({ orphans, owner, repo }); - - if (!issue) { - const { data: created } = await github.rest.issues.create({ - owner, repo, title: CLEANUP_TITLE, body, labels: CLEANUP_LABELS, - }); - core.info(`Issue de limpieza creado: #${created.number}`); - return; - } - - if (issue.body === body && issue.state === 'open') { - core.info('Issue de limpieza sin cambios.'); - return; - } - - await github.rest.issues.update({ - owner, repo, issue_number: issue.number, body, state: 'open', - }); - core.info(`Issue de limpieza actualizado: #${issue.number}`); -} - -function buildCleanupBody({ orphans, owner, repo }) { - const lines = [ - '## 🧹 Archivos que ya no existen en el original', - '', - 'Upstream los eliminó o los movió. `update-origin` nunca borra, así que siguen aquí.', - '', - '**Por qué importa:** el build compila todo el markdown de `src/` sin comprobar si hay', - 'ruta, así que estas páginas viajan a producción como HTML que nadie puede abrir. Además', - 'sus `.en.*` se comparan consigo mismos para siempre, y por eso cuentan como sincronizadas.', - '', - `**Archivos:** ${orphans.length}`, - '', - ]; - - for (const o of orphans) { - const gh = `https://github.com/${owner}/${repo}/blob/main/${o.file}`; - lines.push(`- [ ] [\`${o.path}\`](${gh})`); - } - - lines.push( - '', - '> [!IMPORTANT]', - '> Antes de borrar, comprueba si upstream lo **movió** en vez de eliminarlo. Un renombrado', - '> se ve igual desde aquí: la ruta vieja queda huérfana y aparece una página nueva sin', - '> traducir. En ese caso hay que trasladar la traducción, no tirarla.', - '', - 'Borra también el `.en.*` correspondiente, si lo tiene.', - '', - '---', - '', - 'Generado por `npm run check-translations`.' - ); - - return lines.join('\n'); -} - -/* ------------------------------------------------------------------ */ -/* Sin traducir: un solo issue de tracking */ -/* ------------------------------------------------------------------ */ - -async function syncTracking({ github, core, owner, repo, existing, data }) { - const { untranslated } = data; - - // Los issues de declaración abiertos por contribuidores marcan qué archivos - // ya están reclamados, para no pedir voluntarios dos veces. - const claimed = new Map(); - for (const issue of existing) { - const m = issue.title.match(/^translate:\s*(\S+)/); - if (m && issue.state === 'open') claimed.set(normalize(m[1]), issue.number); - } - - const body = buildTrackingBody({ untranslated, claimed, owner, repo }); - const tracking = existing.find((i) => i.title === TRACKING_TITLE); - - if (!tracking) { - if (untranslated.length === 0) { - core.info('No hay archivos sin traducir; no se crea el issue de tracking.'); - return; - } - const { data: created } = await github.rest.issues.create({ - owner, - repo, - title: TRACKING_TITLE, - body, - labels: TRACKING_LABELS, - }); - core.info(`Issue de tracking creado: #${created.number}`); - return; - } - - if (tracking.body === body && tracking.state === 'open') { - core.info('Issue de tracking sin cambios.'); - return; - } - - await github.rest.issues.update({ - owner, - repo, - issue_number: tracking.number, - body, - state: 'open', - }); - core.info(`Issue de tracking actualizado: #${tracking.number}`); -} - -function buildTrackingBody({ untranslated, claimed, owner, repo }) { - if (untranslated.length === 0) { - return [ - '## 🎉 No hay documentos sin traducir', - '', - 'Todas las páginas del sitio tienen traducción al español. ¡Gracias!', - '', - 'Este issue se actualiza automáticamente cuando `update-origin` trae páginas nuevas.', - ].join('\n'); - } - - const groups = new Map(); - for (const f of untranslated) { - if (!groups.has(f.category)) groups.set(f.category, []); - groups.get(f.category).push(f); - } - - const lines = [ - '## 📋 Documentos sin traducir', - '', - 'Este issue se actualiza solo. Para tomar un archivo, usa su link **📝 Declarar**:', - 'eso abre un issue a tu nombre y marca la casilla aquí.', - '', - `**Pendientes:** ${untranslated.length}`, - '', - ]; - - for (const category of CATEGORY_ORDER) { - const files = groups.get(category); - if (!files?.length) continue; - - lines.push(`### ${CATEGORY_NAMES[category]}`, ''); - for (const f of files.sort((a, b) => a.path.localeCompare(b.path))) { - const gh = `https://github.com/${owner}/${repo}/blob/main/${f.file}`; - const claim = claimed.get(normalize(f.path)); - if (claim) { - lines.push(`- [x] \`${f.path}\` ([GitHub](${gh}) · #${claim})`); - } else { - const url = - `https://github.com/${owner}/${repo}/issues/new` + - `?template=translation-checkout.md` + - `&labels=docs-translation&title=${encodeURIComponent(`translate: ${f.path}`)}`; - lines.push(`- [ ] \`${f.path}\` ([GitHub](${gh}) · [📝 Declarar](${url}))`); - } - } - lines.push(''); - } - - lines.push('---', '', 'Generado por `npm run check-translations`.'); - return lines.join('\n'); -} - -/* ------------------------------------------------------------------ */ -/* Desactualizados: un issue por archivo */ -/* ------------------------------------------------------------------ */ - -async function syncStale({ github, core, owner, repo, existing, data }) { - const { stale } = data; - - const openStale = new Map(); - for (const issue of existing) { - if (issue.state !== 'open') continue; - const m = issue.title.match(/^sync:\s*(\S+)/); - if (m) openStale.set(normalize(m[1]), issue); - } - - for (const file of stale) { - const key = normalize(file.path); - const body = buildStaleBody({ file, owner, repo }); - const found = openStale.get(key); - - if (!found) { - const { data: created } = await github.rest.issues.create({ - owner, - repo, - title: `${STALE_PREFIX} ${file.path}`, - body, - labels: STALE_LABELS, - }); - core.info(`Issue creado: #${created.number} (${file.path})`); - } else if (found.body !== body) { - // El inglés volvió a cambiar desde que se abrió: refrescar el diff. - await github.rest.issues.update({ - owner, - repo, - issue_number: found.number, - body, - }); - core.info(`Issue actualizado: #${found.number} (${file.path})`); - } - - openStale.delete(key); - } - - // Lo que quedó abierto ya no aparece en la detección: se tradujo. - for (const [path, issue] of openStale) { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issue.number, - body: 'La traducción ya está al día con el original. Cerrando automáticamente.', - }); - await github.rest.issues.update({ - owner, - repo, - issue_number: issue.number, - state: 'closed', - state_reason: 'completed', - }); - core.info(`Issue cerrado: #${issue.number} (${path})`); - } -} - -function buildStaleBody({ file, owner, repo }) { - const gh = `https://github.com/${owner}/${repo}/blob/main/${file.file}`; - const en = file.file.replace(/\.md$/, '.en.md'); - - let diff = file.diff; - let truncated = false; - if (diff.length > MAX_DIFF_CHARS) { - diff = diff.slice(0, MAX_DIFF_CHARS); - truncated = true; - } - - const lines = [ - `El original en inglés cambió después de que se tradujo \`${file.path}\`.`, - '', - `- **Archivo a actualizar:** [\`${file.path}\`](${gh})`, - `- **Traducido por última vez en:** \`${file.since}\``, - `- **Cambios en el original:** ${file.prose} líneas de prosa` + - (file.noise ? `, ${file.noise} de formato (ignorables)` : ''), - '', - '## Qué cambió en el original', - '', - 'Solo hace falta aplicar estos cambios al español; el resto del archivo ya está bien.', - '', - '```diff', - diff.trimEnd(), - '```', - ]; - - if (truncated) { - lines.push('', '_Diff recortado. Para verlo completo:_', '', '```shell', `git diff ${file.since} HEAD -- ${en}`, '```'); - } - - lines.push('', '---', '', 'Detectado por `npm run check-translations`. Se cierra solo al actualizarse la traducción.'); - return lines.join('\n'); -} - -/** Los títulos los escriben humanos: normalizar antes de comparar rutas. */ -function normalize(path) { - return path.trim().replace(/^`|`$/g, '').replace(/^adev-es\/src\/content\//, ''); -} diff --git a/.github/scripts/sync-translation-issues.test.mjs b/.github/scripts/sync-translation-issues.test.mjs deleted file mode 100644 index 99c84306..00000000 --- a/.github/scripts/sync-translation-issues.test.mjs +++ /dev/null @@ -1,149 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import syncIssues from './sync-translation-issues.mjs'; - -const context = { repo: { owner: 'angular-hispano', repo: 'angular-docs-es' } }; - -/** Repo de mentira: guarda los issues y registra cada llamada de escritura. */ -function fakeRepo(seed = []) { - const store = seed.map((i) => ({ ...i })); - const actions = []; - let next = 900; - - const github = { - paginate: async () => store, - rest: { - issues: { - listForRepo: 'listForRepo', - create: async ({ title, body, labels }) => { - const number = next++; - actions.push({ op: 'create', title, labels }); - store.push({ number, title, body, state: 'open' }); - return { data: { number } }; - }, - update: async ({ issue_number, body, state }) => { - const i = store.find((x) => x.number === issue_number); - actions.push({ op: 'update', number: issue_number, state }); - if (body !== undefined) i.body = body; - if (state) i.state = state; - return { data: i }; - }, - createComment: async ({ issue_number }) => { - actions.push({ op: 'comment', number: issue_number }); - }, - }, - }, - }; - - return { github, store, actions, core: { info() {} } }; -} - -const datos = (over = {}) => ({ - synced: 300, - stale: [{ path: 'guide/a.md', file: 'adev-es/src/content/guide/a.md', category: 'guide', - since: 'abc1234', prose: 3, noise: 0, diff: '@@ -1 +1 @@\n-old\n+new' }], - untranslated: [{ path: 'guide/b.md', file: 'adev-es/src/content/guide/b.md', category: 'guide' }], - orphans: [{ path: 'guide/c.md', file: 'adev-es/src/content/guide/c.md' }], - ...over, -}); - -test('crea el tracking, el issue de cada desactualizado y el de limpieza', async () => { - const r = fakeRepo(); - await syncIssues({ ...r, context, data: datos() }); - - const creados = r.actions.filter((a) => a.op === 'create').map((a) => a.title); - assert.deepEqual(creados, [ - 'Tracking: documentos sin traducir', - 'sync: guide/a.md', - 'Limpieza: archivos que ya no existen en el original', - ]); -}); - -test('es idempotente: una segunda pasada no hace nada', async () => { - const r = fakeRepo(); - await syncIssues({ ...r, context, data: datos() }); - r.actions.length = 0; - - await syncIssues({ ...r, context, data: datos() }); - assert.deepEqual(r.actions, [], 'no debería tocar nada'); -}); - -test('cierra el issue cuando la traducción se pone al día', async () => { - const r = fakeRepo(); - await syncIssues({ ...r, context, data: datos() }); - r.actions.length = 0; - - await syncIssues({ ...r, context, data: datos({ stale: [] }) }); - - const issue = r.store.find((i) => i.title === 'sync: guide/a.md'); - assert.equal(issue.state, 'closed'); - assert.ok(r.actions.some((a) => a.op === 'comment' && a.number === issue.number)); -}); - -test('cierra la limpieza cuando ya no hay huérfanos', async () => { - const r = fakeRepo(); - await syncIssues({ ...r, context, data: datos() }); - - await syncIssues({ ...r, context, data: datos({ orphans: [] }) }); - assert.equal(r.store.find((i) => i.title.startsWith('Limpieza')).state, 'closed'); -}); - -test('marca como reclamado el archivo que alguien declaró', async () => { - const r = fakeRepo([ - { number: 500, title: 'translate: guide/b.md', body: 'me encargo', state: 'open' }, - ]); - await syncIssues({ ...r, context, data: datos() }); - - const tracking = r.store.find((i) => i.title.startsWith('Tracking')); - assert.match(tracking.body, /- \[x\] `guide\/b\.md`.*#500/); -}); - -// Este es el que importa: el endpoint de issues devuelve también los PRs, y el -// repo tiene pull requests titulados «translate: …» con la misma etiqueta. -test('ignora los pull requests, no solo los issues', async () => { - const r = fakeRepo([ - { number: 101, title: 'translate: guide/b.md', body: '', state: 'open', - pull_request: { url: 'https://api.github.com/…' } }, - ]); - await syncIssues({ ...r, context, data: datos() }); - - const tracking = r.store.find((i) => i.title.startsWith('Tracking')); - assert.match(tracking.body, /- \[ \] `guide\/b\.md`/, 'un PR no reclama el archivo'); -}); - -test('nunca cierra un pull request', async () => { - const r = fakeRepo([ - { number: 102, title: 'sync: guide/z.md', body: '', state: 'open', - pull_request: { url: 'https://api.github.com/…' } }, - ]); - await syncIssues({ ...r, context, data: datos({ stale: [] }) }); - - assert.ok( - !r.actions.some((a) => a.op === 'update' && a.number === 102), - 'cerrar por esta vía cerraría el PR de verdad' - ); -}); - -test('en simulación no escribe nada', async () => { - const r = fakeRepo(); - await syncIssues({ ...r, context, data: datos(), dryRun: true }); - - assert.deepEqual(r.actions, []); - assert.deepEqual(r.store, []); -}); - -test('el diff se recorta por debajo del límite de GitHub', async () => { - const r = fakeRepo(); - await syncIssues({ - ...r, - context, - data: datos({ - stale: [{ path: 'guide/a.md', file: 'adev-es/src/content/guide/a.md', category: 'guide', - since: 'abc1234', prose: 5, noise: 0, diff: 'x'.repeat(80_000) }], - }), - }); - - const issue = r.store.find((i) => i.title === 'sync: guide/a.md'); - assert.ok(issue.body.length < 65_000, `cuerpo de ${issue.body.length} caracteres`); - assert.match(issue.body, /Diff recortado/); -}); diff --git a/.github/workflows/sync-translation-issues.yml b/.github/workflows/sync-translation-issues.yml deleted file mode 100644 index c92f0333..00000000 --- a/.github/workflows/sync-translation-issues.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Sync Translation Issues - -on: - push: - branches: - - main - # Cuando alguien declara o cierra una traducción, el tracking issue debe - # reflejarlo sin esperar al siguiente push. - issues: - types: [opened, closed, reopened, labeled] - workflow_dispatch: - inputs: - dry_run: - description: 'Simular: registrar qué issues se crearían, sin escribir nada' - type: boolean - default: true - -permissions: - contents: read - issues: write - -# Dos ejecuciones simultáneas pelearían por los mismos issues. -concurrency: - group: sync-translation-issues - cancel-in-progress: false - -jobs: - sync: - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - # La detección de traducciones desactualizadas compara el .en.md de - # commits anteriores, así que necesita el historial completo. - fetch-depth: 0 - # No hace falta el submódulo: el .en.md ya guarda el original. - submodules: false - - - name: Setup Node JS - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version-file: '.node-version' - - - name: Install Dependencies - run: npm ci - - - name: Detect translation status - id: detect - run: | - npx zx tools/check-translations.mjs --json > /tmp/status.json || true - echo "count=$(node -p "require('/tmp/status.json').stale.length + require('/tmp/status.json').untranslated.length")" >> "$GITHUB_OUTPUT" - - - name: Sync issues - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - const { readFileSync } = require('node:fs'); - const { default: syncIssues } = await import('${{ github.workspace }}/.github/scripts/sync-translation-issues.mjs'); - const data = JSON.parse(readFileSync('/tmp/status.json', 'utf8')); - const dryRun = context.eventName === 'workflow_dispatch' - && context.payload.inputs?.dry_run !== 'false'; - await syncIssues({ github, context, core, data, dryRun }); diff --git a/UPDATE-ORIGIN.md b/UPDATE-ORIGIN.md index c6edeabf..f4cee803 100644 --- a/UPDATE-ORIGIN.md +++ b/UPDATE-ORIGIN.md @@ -47,10 +47,17 @@ npm run lint-glossary # consistencia terminológica del español ### `check-translations` -Distingue dos problemas que `update-origin` puede dejar atrás: +Distingue cinco estados. Los tres últimos son silenciosos: sin ellos, esos archivos se cuentan como correctos. -- **Desactualizadas** — el archivo ya está traducido (existe su `.en.md`) pero el inglés cambió después. Compara el `.en.md` tal como estaba en el commit donde se tocó por última vez el `.md` contra el `.en.md` actual: ese diff es exactamente lo que falta traducir. Separa los cambios de prosa del ruido de formato para que el reporte sea accionable. -- **Sin traducir** — no existe `.en.md`, así que `update-origin` copió el inglés directamente al `.md`. +| Estado | Qué pasó | +| --- | --- | +| **Desactualizada** | Ya traducida, pero el inglés cambió después. El diff contra el `.en.md` del commit donde se tradujo es exactamente lo que falta. Separa la prosa del ruido de formato. | +| **Sin traducir** | No existe `.en.md`, así que `update-origin` copió el inglés directamente al `.md`. | +| **Sin respaldo** | El `.md` ya está en español pero le falta el `.en.md`. El próximo `update-origin` le escribe inglés encima: es pérdida de trabajo, no deuda pendiente. | +| **Desparejada** | Existe el `.en.md` pero no su `.md`. Casi siempre un typo al crear el respaldo. | +| **Huérfana** | Ya no existe en el original. `update-origin` nunca borra, así que sigue publicándose y su `.en.md` se compara consigo mismo para siempre. | + +El chequeo de huérfanas necesita el submódulo; si no está inicializado te avisa en vez de dar el resultado por bueno. Opciones: @@ -66,14 +73,22 @@ git fetch origin pull//head:refs/tmp/pr npm run check-translations -- --ref=refs/tmp/pr ``` -### Issues automáticos +### Crear los issues + +Los issues de traducción se crean **a mano**, agrupados por sección, siguiendo la convención del repo: -En cada push a `main`, el workflow `sync-translation-issues` convierte el resultado de la detección en trabajo reclamable, con dos formatos según el tipo de problema: +``` +[Angular 22.1] Traducir guías de Signal Forms +Traducir - Press Kit +``` -- **Sin traducir** → un único issue de tracking con checkboxes, agrupado por sección. Son muchos y de baja rotación, así que abrir un issue por cada uno sería ruido. Cada entrada trae un link **📝 Declarar** que pre-rellena un issue a tu nombre; al crearlo, la casilla se marca sola. -- **Desactualizados** → un issue individual por archivo, con el diff del original incluido. Son pocos y cada uno es una unidad de trabajo concreta y asignable. Se cierran solos cuando la traducción se pone al día. +`check-translations` te da la lista para componerlos, y `--json` la deja en un formato cómodo de recortar: + +```shell +npm run check-translations -- --json +``` -El workflow también corre cuando se abre o cierra un issue, así que el tracking refleja los reclamos sin esperar al siguiente push. +Se decidió no automatizarlo: agrupar por sección es una decisión editorial que un script no acierta, y un issue generado competiría como segunda fuente de verdad con los que ya se mantienen a mano. ### `lint-glossary` diff --git a/package.json b/package.json index ca04bb8e..790dc34d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": "node --test tools/*.test.mjs .github/scripts/*.test.mjs", + "test": "node --test tools/*.test.mjs", "build": "zx tools/build.mjs", "start": "zx tools/watch.mjs", "update-origin": "zx tools/update-origin.mjs", From b9ddad458229f9b9358c436bb8c64d5a781bd562 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 18:18:40 -0400 Subject: [PATCH 15/28] chore: reactivar navigation-entries ahora que v22.1 lo trae MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Estaba comentado porque la ruta no existía en el origin de v21.0.1 y habría hecho fallar el guard. Con el merge de #192 el submódulo pasa a v22.1, donde sí existe, y el test de objetivos lo confirma. --- tools/lib/targets.mjs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tools/lib/targets.mjs b/tools/lib/targets.mjs index c07798cf..a153eddf 100644 --- a/tools/lib/targets.mjs +++ b/tools/lib/targets.mjs @@ -22,9 +22,7 @@ export const copyTargets = [ ], // Navegación 'src/app/routing/sub-navigation-data.ts', - // 'src/app/routing/navigation-entries/index.ts' llega con v22.1; a día de hoy - // no existe en el origin fijado y haría fallar el guard. Lo añade el PR #192 - // junto con el bump, que es donde la ruta es válida. + 'src/app/routing/navigation-entries/index.ts', // Interfaz del sitio 'src/app/core/constants/links.ts', 'src/app/core/layout/navigation/navigation.component.html', From 030d9d564c231506bc4a17c51d04235a47cba360 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 18:24:01 -0400 Subject: [PATCH 16/28] =?UTF-8?q?feat:=20plantillas=20de=20issue=20derivad?= =?UTF-8?q?as=20de=20la=20convenci=C3=B3n=20real=20del=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salen de analizar los 72 issues del historial, no de suposiciones. Lo que muestran los datos: - 67 de 72 (93%) son de traducción. El resto es mantenimiento suelto: actualizar un enlace, el copyright, escribir una convención de commits. - El título dominante es «Traducir - », con una variante para los bumps de versión: «[Angular 22.1] Traducir guías de Signal Forms». - El cuerpo evolucionó de una línea señalando una carpeta (#107, #127) a una lista de casillas por archivo (#185-#190). La segunda forma es mejor: permite repartir el trabajo y ver el avance. Las plantillas la adoptan. - 12 issues (17%) no llevan etiqueta, incluidos los seis más recientes. Los formularios la aplican solos, que es la mitad de su valor. Tres formularios: - traducir.yml el caso dominante, con lista de archivos - actualizar-traduccion.yml para las 31 desactualizadas, que no tenían convención porque hasta ahora no se detectaban - error-traduccion.yml para quien lee y encuentra una errata Cada uno lleva al final los comandos concretos del flujo, incluida la advertencia de no repetir el `cp` sobre un archivo que ya tiene .en.md. config.yml deja habilitados los issues en blanco: ese 7% de mantenimiento no encaja en ninguna plantilla y forzarlo sería peor. Con tests, porque un formulario con el esquema mal cae en silencio: GitHub lo ignora y ofrece un issue en blanco. Solo se nota cuando alguien abre uno y no trae ni etiqueta ni estructura. --- .../ISSUE_TEMPLATE/actualizar-traduccion.yml | 67 ++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 17 ++++ .github/ISSUE_TEMPLATE/error-traduccion.yml | 42 +++++++++ .github/ISSUE_TEMPLATE/traducir.yml | 69 ++++++++++++++ tools/issue-templates.test.mjs | 91 +++++++++++++++++++ 5 files changed, 286 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/actualizar-traduccion.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/error-traduccion.yml create mode 100644 .github/ISSUE_TEMPLATE/traducir.yml create mode 100644 tools/issue-templates.test.mjs diff --git a/.github/ISSUE_TEMPLATE/actualizar-traduccion.yml b/.github/ISSUE_TEMPLATE/actualizar-traduccion.yml new file mode 100644 index 00000000..50372190 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/actualizar-traduccion.yml @@ -0,0 +1,67 @@ +name: Actualizar traducción desactualizada +description: El original en inglés cambió después de traducirse +title: 'Actualizar - ' +labels: ['docs-translation'] +body: + - type: markdown + attributes: + value: | + Para estos archivos **no se retraduce nada**: solo se aplica al español el + cambio que ocurrió en inglés. El resto de la página ya está bien. + + Para ver cuáles están desactualizados: + + ```shell + npm run check-translations + ``` + + - type: input + id: archivo + attributes: + label: Archivo + description: Ruta relativa a `adev-es/src/content/`. + placeholder: guide/templates/ng-content.md + validations: + required: true + + - type: input + id: baseline + attributes: + label: Traducido por última vez en + description: El commit que reporta `check-translations` en la línea «desde …». + placeholder: ca450f6 + validations: + required: true + + - type: textarea + id: cambios + attributes: + label: Qué cambió en el original + description: | + Pega aquí el diff. Lo obtienes con el comando que te da `check-translations`: + `git diff -- adev-es/src/content/.en.md` + render: diff + validations: + required: true + + - type: markdown + attributes: + value: | + --- + + **Cómo aplicarlo** + + ```shell + # Genera la orden de trabajo: qué bloques hay que tocar y su texto exacto + npm run plan-translation -- .md + + # Aplica solo esos bloques, y verifica + npm run verify-translation -- .md + ``` + + Si `plan-translation` responde `manual`, el cambio es una reestructuración y no + un delta: traduce esa sección entera, con la traducción actual delante como + referencia de estilo y terminología. + + No toques nada fuera de los bloques indicados —`verify-translation` lo comprueba— + y commitea `.md` y `.en.md` juntos. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..0799f6e4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +# Se dejan habilitados los issues en blanco: el 7 % del historial son tareas de +# mantenimiento que no encajan en ninguna plantilla (actualizar un enlace, revisar +# el copyright, escribir una convención de commits). +blank_issues_enabled: true + +contact_links: + - name: Comunidad en Discord + url: https://discord.gg/4jgk3ddgAx + about: Dudas sobre cómo traducir algo, o sobre qué término usar. + + - name: Guía de contribución + url: https://github.com/angular-hispano/angular-docs-es/blob/main/CONTRIBUTING.md + about: Cómo preparar el entorno y abrir tu primer PR. + + - name: Problema con la documentación original en inglés + url: https://github.com/angular/angular/issues + about: Si el error también está en angular.dev, repórtalo en el repo de Angular. diff --git a/.github/ISSUE_TEMPLATE/error-traduccion.yml b/.github/ISSUE_TEMPLATE/error-traduccion.yml new file mode 100644 index 00000000..30687538 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/error-traduccion.yml @@ -0,0 +1,42 @@ +name: Reportar un error en la traducción +description: Una errata, un término mal traducido o algo que no se entiende +title: 'Corregir - ' +labels: ['docs-translation'] +body: + - type: markdown + attributes: + value: | + Gracias por leer con atención. No hace falta que propongas el arreglo: + con señalar dónde está basta. + + - type: input + id: pagina + attributes: + label: Página + description: La URL en angular.lat, o la ruta del archivo. + placeholder: https://angular.lat/guide/signals + validations: + required: true + + - type: textarea + id: problema + attributes: + label: Qué está mal + description: Copia el texto tal como aparece hoy. + validations: + required: true + + - type: textarea + id: propuesta + attributes: + label: Cómo debería decir + description: Opcional. + + - type: markdown + attributes: + value: | + --- + + Si es una cuestión de terminología, mira si el término está en + [`glosario.yml`](https://github.com/angular-hispano/angular-docs-es/blob/main/glosario.yml). Si no está y crees que debería, + dilo aquí: las reglas del glosario se aplican a todo el repo. diff --git a/.github/ISSUE_TEMPLATE/traducir.yml b/.github/ISSUE_TEMPLATE/traducir.yml new file mode 100644 index 00000000..5278c2a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/traducir.yml @@ -0,0 +1,69 @@ +name: Traducir documentación +description: Abrir trabajo de traducción para una sección o un conjunto de páginas +title: 'Traducir - ' +labels: ['docs-translation'] +body: + - type: markdown + attributes: + value: | + Para saber qué falta por traducir: + + ```shell + npm run check-translations + ``` + + Si las páginas las trae una versión nueva de Angular, antepón la versión al título: + `[Angular 22.1] Traducir guías de Signal Forms`. + + - type: input + id: seccion + attributes: + label: Sección + description: Qué parte de la documentación cubre este issue. + placeholder: guide/forms/signals/ + validations: + required: true + + - type: textarea + id: archivos + attributes: + label: Archivos + description: | + Una casilla por archivo, para poder repartir el trabajo y ver el avance. + Las rutas son relativas a `adev-es/src/content/`. + value: | + - [ ] `archivo.md` + - [ ] `otro-archivo.md` + validations: + required: true + + - type: textarea + id: contexto + attributes: + label: Contexto + description: Opcional. Por ejemplo, si son páginas nuevas de una versión concreta. + placeholder: Páginas nuevas añadidas en Angular 22.1. + + - type: markdown + attributes: + value: | + --- + + **Cómo traducir cada archivo** + + ```shell + # 1. Si el archivo NO tiene .en.md todavía, respalda el original. + # Si YA lo tiene, no copies nada: sobrescribirías el respaldo con español. + cp adev-es/src/content/.md adev-es/src/content/.en.md + + # 2. Traduce el .md + + # 3. Verifica antes de abrir el PR + npm run lint-glossary + npm run check-translations + ``` + + El `.en.md` es lo que protege tu traducción: sin él, el próximo `update-origin` + la sobrescribe con inglés. Commitea siempre `.md` y `.en.md` juntos. + + Los términos acordados están en [`glosario.yml`](https://github.com/angular-hispano/angular-docs-es/blob/main/glosario.yml). diff --git a/tools/issue-templates.test.mjs b/tools/issue-templates.test.mjs new file mode 100644 index 00000000..ad5514d7 --- /dev/null +++ b/tools/issue-templates.test.mjs @@ -0,0 +1,91 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { YAML } from 'zx'; + +/** + * Un formulario de issue con el esquema mal cae en silencio: GitHub lo ignora y + * ofrece un issue en blanco, así que el error solo se nota cuando alguien abre + * uno y no trae ni etiqueta ni estructura. Estos tests lo hacen ruidoso. + */ + +const DIR = resolve(import.meta.dirname, '../.github/ISSUE_TEMPLATE'); +const TIPOS = ['markdown', 'input', 'textarea', 'dropdown', 'checkboxes']; + +const archivos = readdirSync(DIR).filter((f) => f.endsWith('.yml')); +const formularios = archivos + .filter((f) => f !== 'config.yml') + .map((f) => [f, YAML.parse(readFileSync(resolve(DIR, f), 'utf8'))]); + +test('hay formularios y todos parsean', () => { + assert.ok(formularios.length >= 3, `solo ${formularios.length} formularios`); +}); + +test('cada formulario trae los campos que exige GitHub', () => { + for (const [f, d] of formularios) { + for (const k of ['name', 'description', 'body']) { + assert.ok(d[k], `${f}: falta "${k}"`); + } + assert.ok(Array.isArray(d.body) && d.body.length, `${f}: body vacío`); + } +}); + +test('todos los tipos de campo son válidos', () => { + for (const [f, d] of formularios) { + for (const campo of d.body) { + assert.ok(TIPOS.includes(campo.type), `${f}: tipo desconocido "${campo.type}"`); + } + } +}); + +test('los campos que no son markdown llevan id y label', () => { + for (const [f, d] of formularios) { + for (const campo of d.body.filter((c) => c.type !== 'markdown')) { + assert.ok(campo.id, `${f}: campo sin id`); + assert.ok(campo.attributes?.label, `${f}: campo "${campo.id}" sin label`); + } + } +}); + +// El 17 % de los issues del repo no tiene etiqueta, incluidos los seis más +// recientes. Las plantillas existen en parte para que eso deje de pasar. +test('todos los formularios aplican la etiqueta docs-translation', () => { + for (const [f, d] of formularios) { + assert.ok(Array.isArray(d.labels), `${f}: sin labels`); + assert.ok(d.labels.includes('docs-translation'), `${f}: no aplica docs-translation`); + } +}); + +test('todos prerrellenan el título con su prefijo', () => { + for (const [f, d] of formularios) { + assert.match(d.title ?? '', /^\S.* - $/, `${f}: título "${d.title}" no sigue "Verbo - "`); + } +}); + +test('cada formulario pide al menos un dato obligatorio', () => { + for (const [f, d] of formularios) { + const req = d.body.filter((c) => c.validations?.required); + assert.ok(req.length, `${f}: nada obligatorio, se pueden abrir issues vacíos`); + } +}); + +test('config.yml es válido y deja abrir issues en blanco', () => { + const c = YAML.parse(readFileSync(resolve(DIR, 'config.yml'), 'utf8')); + assert.equal(typeof c.blank_issues_enabled, 'boolean'); + assert.ok(Array.isArray(c.contact_links)); + for (const l of c.contact_links) { + assert.ok(l.name && l.url && l.about, `enlace incompleto: ${JSON.stringify(l)}`); + assert.match(l.url, /^https:\/\//, `${l.name}: la URL debe ser absoluta`); + } +}); + +test('los enlaces del cuerpo son absolutos', () => { + // Un enlace relativo se resuelve contra /issues/new y rompe con facilidad. + for (const [f, d] of formularios) { + for (const campo of d.body.filter((c) => c.type === 'markdown')) { + const relativos = [...campo.attributes.value.matchAll(/\]\((?!https?:)([^)]+)\)/g)]; + assert.deepEqual(relativos.map((m) => m[1]), [], `${f}: enlace relativo`); + } + } +}); From 725cbbc04010b524b1ad64ef8bd78fe934a79bc3 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 18:32:48 -0400 Subject: [PATCH 17/28] refactor: una sola plantilla de issue, no tres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las otras dos eran especulativas y los datos del repo lo dicen claro. Reportar erratas: cero issues en 72. Nunca ha pasado. Una plantilla dedicada sería una entrada más en el selector, restándole visibilidad a la única que se usa de verdad. Los issues en blanco siguen habilitados, así que quien quiera reportar una puede; si algún día se vuelve habitual, se añade. Actualizar desactualizadas: también cero, y hay una razón de fondo para no darle plantilla propia. El repo AGRUPA — #185-#190 abren un issue por sección, no por archivo. Con las 31 desactualizadas pasaría lo mismo, y ahí la estructura del issue es idéntica a la de traducir: una lista de archivos con casillas. Lo que cambia es el procedimiento, no el formulario. Así que se colapsa en traducir.yml con un desplegable de tipo, y las instrucciones de ambos caminos van al final, separadas. Una sola entrada en el selector, que es lo que corresponde a un repo donde el 93 % de los issues son lo mismo. --- .../ISSUE_TEMPLATE/actualizar-traduccion.yml | 67 ------------------- .github/ISSUE_TEMPLATE/config.yml | 10 ++- .github/ISSUE_TEMPLATE/error-traduccion.yml | 42 ------------ .github/ISSUE_TEMPLATE/traducir.yml | 51 +++++++++++--- tools/issue-templates.test.mjs | 12 +++- 5 files changed, 58 insertions(+), 124 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/actualizar-traduccion.yml delete mode 100644 .github/ISSUE_TEMPLATE/error-traduccion.yml diff --git a/.github/ISSUE_TEMPLATE/actualizar-traduccion.yml b/.github/ISSUE_TEMPLATE/actualizar-traduccion.yml deleted file mode 100644 index 50372190..00000000 --- a/.github/ISSUE_TEMPLATE/actualizar-traduccion.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Actualizar traducción desactualizada -description: El original en inglés cambió después de traducirse -title: 'Actualizar - ' -labels: ['docs-translation'] -body: - - type: markdown - attributes: - value: | - Para estos archivos **no se retraduce nada**: solo se aplica al español el - cambio que ocurrió en inglés. El resto de la página ya está bien. - - Para ver cuáles están desactualizados: - - ```shell - npm run check-translations - ``` - - - type: input - id: archivo - attributes: - label: Archivo - description: Ruta relativa a `adev-es/src/content/`. - placeholder: guide/templates/ng-content.md - validations: - required: true - - - type: input - id: baseline - attributes: - label: Traducido por última vez en - description: El commit que reporta `check-translations` en la línea «desde …». - placeholder: ca450f6 - validations: - required: true - - - type: textarea - id: cambios - attributes: - label: Qué cambió en el original - description: | - Pega aquí el diff. Lo obtienes con el comando que te da `check-translations`: - `git diff -- adev-es/src/content/.en.md` - render: diff - validations: - required: true - - - type: markdown - attributes: - value: | - --- - - **Cómo aplicarlo** - - ```shell - # Genera la orden de trabajo: qué bloques hay que tocar y su texto exacto - npm run plan-translation -- .md - - # Aplica solo esos bloques, y verifica - npm run verify-translation -- .md - ``` - - Si `plan-translation` responde `manual`, el cambio es una reestructuración y no - un delta: traduce esa sección entera, con la traducción actual delante como - referencia de estilo y terminología. - - No toques nada fuera de los bloques indicados —`verify-translation` lo comprueba— - y commitea `.md` y `.en.md` juntos. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0799f6e4..ac62ba43 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,6 +1,10 @@ -# Se dejan habilitados los issues en blanco: el 7 % del historial son tareas de -# mantenimiento que no encajan en ninguna plantilla (actualizar un enlace, revisar -# el copyright, escribir una convención de commits). +# Se dejan habilitados los issues en blanco a propósito. Cubren dos cosas: +# +# - El 7 % del historial que es mantenimiento suelto y no encaja en la plantilla +# (actualizar un enlace, revisar el copyright, una convención de commits). +# - Reportar una errata. En 72 issues nadie lo ha hecho nunca, así que una +# plantilla dedicada sería una entrada más en el selector sin demanda que la +# justifique. Si algún día empieza a pasar, se añade. blank_issues_enabled: true contact_links: diff --git a/.github/ISSUE_TEMPLATE/error-traduccion.yml b/.github/ISSUE_TEMPLATE/error-traduccion.yml deleted file mode 100644 index 30687538..00000000 --- a/.github/ISSUE_TEMPLATE/error-traduccion.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Reportar un error en la traducción -description: Una errata, un término mal traducido o algo que no se entiende -title: 'Corregir - ' -labels: ['docs-translation'] -body: - - type: markdown - attributes: - value: | - Gracias por leer con atención. No hace falta que propongas el arreglo: - con señalar dónde está basta. - - - type: input - id: pagina - attributes: - label: Página - description: La URL en angular.lat, o la ruta del archivo. - placeholder: https://angular.lat/guide/signals - validations: - required: true - - - type: textarea - id: problema - attributes: - label: Qué está mal - description: Copia el texto tal como aparece hoy. - validations: - required: true - - - type: textarea - id: propuesta - attributes: - label: Cómo debería decir - description: Opcional. - - - type: markdown - attributes: - value: | - --- - - Si es una cuestión de terminología, mira si el término está en - [`glosario.yml`](https://github.com/angular-hispano/angular-docs-es/blob/main/glosario.yml). Si no está y crees que debería, - dilo aquí: las reglas del glosario se aplican a todo el repo. diff --git a/.github/ISSUE_TEMPLATE/traducir.yml b/.github/ISSUE_TEMPLATE/traducir.yml index 5278c2a6..9f0dae8a 100644 --- a/.github/ISSUE_TEMPLATE/traducir.yml +++ b/.github/ISSUE_TEMPLATE/traducir.yml @@ -1,12 +1,12 @@ name: Traducir documentación -description: Abrir trabajo de traducción para una sección o un conjunto de páginas +description: Trabajo de traducción para una sección o un conjunto de páginas title: 'Traducir - ' labels: ['docs-translation'] body: - type: markdown attributes: value: | - Para saber qué falta por traducir: + Para saber qué falta: ```shell npm run check-translations @@ -15,6 +15,17 @@ body: Si las páginas las trae una versión nueva de Angular, antepón la versión al título: `[Angular 22.1] Traducir guías de Signal Forms`. + - type: dropdown + id: tipo + attributes: + label: Tipo de trabajo + description: Cambia el procedimiento, no el formulario. + options: + - Traducir páginas que están en inglés + - Actualizar traducciones que se quedaron desactualizadas + validations: + required: true + - type: input id: seccion attributes: @@ -41,7 +52,7 @@ body: id: contexto attributes: label: Contexto - description: Opcional. Por ejemplo, si son páginas nuevas de una versión concreta. + description: Opcional. Por ejemplo, de qué versión vienen las páginas. placeholder: Páginas nuevas añadidas en Angular 22.1. - type: markdown @@ -49,21 +60,39 @@ body: value: | --- - **Cómo traducir cada archivo** + ### Si vas a traducir una página nueva ```shell - # 1. Si el archivo NO tiene .en.md todavía, respalda el original. - # Si YA lo tiene, no copies nada: sobrescribirías el respaldo con español. + # 1. Respalda el original. Este paso es lo que protege tu trabajo: + # sin .en.md, el próximo update-origin te sobrescribe con inglés. cp adev-es/src/content/.md adev-es/src/content/.en.md # 2. Traduce el .md + ``` + + > [!WARNING] + > Si el archivo **ya tiene** su `.en.md`, no ejecutes ese `cp`: escribirías español + > sobre el respaldo y se perdería el registro de qué inglés se tradujo. + + ### Si vas a actualizar una traducción desactualizada + + No se retraduce nada: solo se aplica al español el cambio que ocurrió en inglés. - # 3. Verifica antes de abrir el PR + ```shell + npm run plan-translation -- .md # qué bloques tocar, con su texto exacto + npm run verify-translation -- .md # comprueba que no tocaste nada más + ``` + + Si `plan-translation` responde `manual`, el cambio es una reestructuración: traduce + esa sección entera con la traducción actual delante, como referencia de estilo. + + ### En ambos casos + + ```shell npm run lint-glossary npm run check-translations ``` - El `.en.md` es lo que protege tu traducción: sin él, el próximo `update-origin` - la sobrescribe con inglés. Commitea siempre `.md` y `.en.md` juntos. - - Los términos acordados están en [`glosario.yml`](https://github.com/angular-hispano/angular-docs-es/blob/main/glosario.yml). + Commitea `.md` y `.en.md` **juntos**: separarlos deja el archivo marcado como + desactualizado para siempre. Los términos acordados están en + [`glosario.yml`](https://github.com/angular-hispano/angular-docs-es/blob/main/glosario.yml). diff --git a/tools/issue-templates.test.mjs b/tools/issue-templates.test.mjs index ad5514d7..3d7a554a 100644 --- a/tools/issue-templates.test.mjs +++ b/tools/issue-templates.test.mjs @@ -18,8 +18,18 @@ const formularios = archivos .filter((f) => f !== 'config.yml') .map((f) => [f, YAML.parse(readFileSync(resolve(DIR, f), 'utf8'))]); +// Uno solo, a propósito: el 93 % de los issues del repo son de traducción, y las +// dos variantes (traducir / actualizar) comparten estructura. Un selector con +// varias entradas restaría visibilidad a la única que se usa. test('hay formularios y todos parsean', () => { - assert.ok(formularios.length >= 3, `solo ${formularios.length} formularios`); + assert.ok(formularios.length >= 1, 'no hay ningún formulario'); +}); + +test('el formulario distingue traducir de actualizar', () => { + const [, d] = formularios.find(([f]) => f === 'traducir.yml'); + const tipo = d.body.find((c) => c.id === 'tipo'); + assert.equal(tipo?.type, 'dropdown', 'falta el desplegable de tipo'); + assert.equal(tipo.attributes.options.length, 2); }); test('cada formulario trae los campos que exige GitHub', () => { From e6d8903d7f0f66fdcb1f9245912b34d856014b43 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 18:39:30 -0400 Subject: [PATCH 18/28] =?UTF-8?q?feat:=20agrupar=20el=20pendiente=20en=20l?= =?UTF-8?q?otes=20del=20tama=C3=B1o=20de=20un=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm run check-translations -- --issues La regla sale de cómo agrupa el repo: por carpeta. El matiz está en qué hacer con las que solo aportan un archivo, y sin resolverlo el agrupado no sirve para nada: las 31 desactualizadas daban 31 grupos de uno, porque cada paso de tutorial vive en su propia carpeta (tutorials/learn-angular/steps/11-optimizing-images/README.md). Se resuelve subiendo de nivel: un grupo que no llega al mínimo cede sus archivos al padre, y se repite hasta que nadie pueda subir más. Lo que llega a la raíz sin agrupar queda como misceláneas. Esto reemplaza al caso especial para tutoriales que había prototipado, y da el mismo resultado sin nombrar a nadie. Sobre el pendiente de hoy: sin traducir 38 archivos → 7 issues desactualizadas 31 archivos → 4 issues Los 7 de traducir reproducen casi exactamente los que ya existen a mano (#185-#190 y #197), que es la señal de que la regla capta la convención real. El comando no crea nada: escribe los borradores. El título lo pone una persona, porque nombrar la sección —«Guías de Errores», no «reference/errors»— es una decisión editorial que un script no acierta. Por eso cada borrador sale marcado con «← renombra esto». En los desactualizados se añade el conteo de líneas de prosa por archivo, que sale gratis del detector y distingue de un vistazo el trabajo de dos minutos del de media hora. Con 11 tests, incluidos los invariantes que importan: que ningún archivo se pierda ni se duplique al subir de nivel, y que no entre en bucle con rutas sin carpeta. --- tools/check-translations.mjs | 45 +++++++++++++++++++ tools/grouping.test.mjs | 83 ++++++++++++++++++++++++++++++++++++ tools/lib/grouping.mjs | 62 +++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 tools/grouping.test.mjs create mode 100644 tools/lib/grouping.mjs diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs index 6fcbf804..8d508161 100644 --- a/tools/check-translations.mjs +++ b/tools/check-translations.mjs @@ -2,6 +2,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { $, chalk, argv, glob } from 'zx'; import { copyTargets, enPathOf, isEnFile, matchesTarget, sourcePathOf } from './lib/targets.mjs'; +import { agrupar, esMiscelanea, relativo } from './lib/grouping.mjs'; const ROOT = resolve(import.meta.dirname, '..'); @@ -38,6 +39,7 @@ const ROOT = resolve(import.meta.dirname, '..'); * npm run check-translations -- --ref=refs/tmp/pr192 (verificar otra rama) * npm run check-translations -- --diff (mostrar los diffs) * npm run check-translations -- --json (salida para automatización) + * npm run check-translations -- --issues (borradores de issue por lote) */ $.verbose = false; @@ -197,6 +199,8 @@ try { if (argv.json) { reportJson(payload); + } else if (argv.issues) { + reportIssues(payload); } else { report({ ...payload, ref }); } @@ -374,6 +378,47 @@ function reportJson({ stale, untranslated, unprotected, orphans, unpaired, skipp ); } +/** + * Borradores de issue, agrupados como los agrupa el repo: por carpeta, subiendo + * de nivel cuando una no reúne suficientes archivos. No crea nada — el título + * final lo pone una persona, porque nombrar la sección («Guías de Errores») es + * una decisión editorial que un script no acierta. + */ +function reportIssues({ stale, untranslated }) { + const lotes = [ + ['Traducir', untranslated.map((f) => ({ path: short(f) }))], + ['Actualizar', stale.filter((s) => s.prose > 0).map((s) => ({ path: short(s.source), prosa: s.prose }))], + ]; + + for (const [verbo, items] of lotes) { + if (!items.length) continue; + const porRuta = new Map(items.map((i) => [i.path, i])); + const grupos = agrupar(items.map((i) => i.path)); + + console.log(chalk.cyan(`\n${'═'.repeat(64)}`)); + console.log(chalk.cyan(`${verbo.toUpperCase()} · ${items.length} archivos → ${grupos.length} issues`)); + console.log(chalk.cyan('═'.repeat(64))); + + for (const g of grupos) { + const nombre = esMiscelanea(g) ? 'páginas sueltas' : g.carpeta; + console.log(`\n${chalk.bold(`${verbo} - `)}${chalk.dim(`«${nombre}» ← renombra esto`)}`); + console.log(chalk.dim(`etiqueta: docs-translation · ${g.archivos.length} archivos\n`)); + + if (verbo === 'Actualizar') { + console.log('El original cambió después de traducirse. No hay que retraducir:'); + console.log('solo aplicar al español el cambio que ocurrió en inglés.\n'); + } + + for (const a of g.archivos) { + const it = porRuta.get(a); + const sufijo = it.prosa ? chalk.dim(` (${it.prosa} líneas)`) : ''; + console.log(`- [ ] \`${relativo(a, g.carpeta)}\`${sufijo}`); + } + } + } + console.log(); +} + function report({ stale, untranslated, unprotected, orphans, unpaired, skipped, synced, analyzed, originAvailable, ref }) { const relevant = stale.filter((s) => s.prose > 0); const cosmetic = stale.filter((s) => s.prose === 0); diff --git a/tools/grouping.test.mjs b/tools/grouping.test.mjs new file mode 100644 index 00000000..664cabf1 --- /dev/null +++ b/tools/grouping.test.mjs @@ -0,0 +1,83 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { agrupar, esMiscelanea, relativo } from './lib/grouping.mjs'; + +const resumen = (paths, min) => + agrupar(paths, min).map((g) => [g.carpeta || '(raíz)', g.archivos.length]); + +test('los archivos de una misma carpeta forman un grupo', () => { + assert.deepEqual(resumen(['ai/a.md', 'ai/b.md', 'ai/c.md']), [['ai', 3]]); +}); + +test('una carpeta con un solo archivo sube de nivel', () => { + // guide/di aporta 2 y se sostiene; guide/x aporta 1 y sube a guide, + // donde tampoco llega a 2, así que acaba en misceláneas. + assert.deepEqual(resumen(['guide/di/a.md', 'guide/di/b.md', 'guide/x/solo.md']), [ + ['guide/di', 2], + ['(raíz)', 1], + ]); +}); + +test('dos carpetas sueltas se juntan en su padre común', () => { + assert.deepEqual(resumen(['guide/a/uno.md', 'guide/b/dos.md']), [['guide', 2]]); +}); + +// El caso que motivó la regla: cada paso de un tutorial vive en su propia +// carpeta, así que sin subir de nivel darían un grupo por archivo. +test('los pasos de un tutorial se agrupan en el tutorial', () => { + const pasos = [ + 'tutorials/signals/steps/1-uno/README.md', + 'tutorials/signals/steps/2-dos/README.md', + 'tutorials/signals/steps/3-tres/README.md', + ]; + assert.deepEqual(resumen(pasos), [['tutorials/signals/steps', 3]]); +}); + +test('tutoriales distintos no se mezclan si cada uno se sostiene', () => { + const paths = [ + 'tutorials/signals/steps/1/README.md', + 'tutorials/signals/steps/2/README.md', + 'tutorials/forms/steps/1/README.md', + 'tutorials/forms/steps/2/README.md', + ]; + assert.deepEqual(resumen(paths), [ + ['tutorials/forms/steps', 2], + ['tutorials/signals/steps', 2], + ]); +}); + +test('lo que llega a la raíz sin agrupar queda como misceláneas', () => { + const g = agrupar(['a.md', 'otra/cosa.md']); + assert.equal(g.length, 1); + assert.ok(esMiscelanea(g[0])); + assert.equal(g[0].archivos.length, 2); +}); + +test('el mínimo es configurable', () => { + const paths = ['x/a.md', 'x/b.md', 'x/c.md', 'y/d.md', 'y/e.md']; + assert.deepEqual(resumen(paths, 3), [['x', 3], ['(raíz)', 2]]); +}); + +test('ningún archivo se pierde ni se duplica', () => { + const paths = [ + 'reference/errors/NG01.md', 'reference/errors/NG02.md', 'reference/cli.md', + 'guide/forms/signals/a.md', 'guide/forms/signals/b.md', 'events/v21.md', + ]; + const total = agrupar(paths).flatMap((g) => g.archivos); + assert.equal(total.length, paths.length); + assert.deepEqual([...total].sort(), [...paths].sort()); +}); + +test('los grupos salen de mayor a menor', () => { + const g = agrupar(['a/1.md', 'a/2.md', 'a/3.md', 'b/1.md', 'b/2.md']); + assert.deepEqual(g.map((x) => x.archivos.length), [3, 2]); +}); + +test('relativo recorta el prefijo del grupo', () => { + assert.equal(relativo('tutorials/signals/steps/1-uno/README.md', 'tutorials/signals/steps'), '1-uno/README.md'); + assert.equal(relativo('events/v21.md', ''), 'events/v21.md'); +}); + +test('no entra en bucle con rutas sin carpeta', () => { + assert.deepEqual(resumen(['a.md', 'b.md']), [['(raíz)', 2]]); +}); diff --git a/tools/lib/grouping.mjs b/tools/lib/grouping.mjs new file mode 100644 index 00000000..4ad0a310 --- /dev/null +++ b/tools/lib/grouping.mjs @@ -0,0 +1,62 @@ +/** + * Agrupa archivos pendientes en lotes del tamaño de un issue. + * + * La regla sale de cómo agrupa el repo: por carpeta. El matiz es qué hacer con + * las carpetas que solo aportan un archivo — sin él, los tutoriales se + * desintegran, porque cada paso vive en su propia carpeta + * (`tutorials/learn-angular/steps/11-optimizing-images/README.md`) y 31 archivos + * darían 31 grupos de uno. + * + * Se resuelve subiendo de nivel: un grupo que no llega al mínimo cede sus + * archivos a la carpeta padre, y se repite hasta que nadie más pueda subir. Lo + * que llega a la raíz sin agrupar es el cajón de misceláneas. + */ + +const MINIMO = 2; + +const padre = (ruta) => (ruta.includes('/') ? ruta.slice(0, ruta.lastIndexOf('/')) : ''); + +/** + * @param {string[]} paths rutas relativas al directorio de contenido + * @param {number} minimo archivos mínimos para que un grupo se sostenga + * @returns {Array<{carpeta: string, archivos: string[]}>} de mayor a menor + */ +export function agrupar(paths, minimo = MINIMO) { + const grupo = new Map(paths.map((p) => [p, padre(p)])); + + for (;;) { + const cuenta = new Map(); + for (const c of grupo.values()) cuenta.set(c, (cuenta.get(c) ?? 0) + 1); + + let cambió = false; + for (const [p, c] of grupo) { + // La raíz no tiene padre: lo que llega ahí se queda como misceláneas. + if (c !== '' && cuenta.get(c) < minimo) { + grupo.set(p, padre(c)); + cambió = true; + } + } + if (!cambió) break; + } + + const salida = new Map(); + for (const [p, c] of grupo) { + if (!salida.has(c)) salida.set(c, []); + salida.get(c).push(p); + } + + return [...salida.entries()] + .map(([carpeta, archivos]) => ({ carpeta, archivos: archivos.sort() })) + .sort((a, b) => b.archivos.length - a.archivos.length || a.carpeta.localeCompare(b.carpeta)); +} + +/** ¿Es el cajón de sueltos? */ +export const esMiscelanea = (grupo) => grupo.carpeta === ''; + +/** + * Recorta el prefijo común para que la lista del issue se lea sin ruido: dentro + * de un grupo la carpeta ya está en el título. + */ +export function relativo(archivo, carpeta) { + return carpeta && archivo.startsWith(`${carpeta}/`) ? archivo.slice(carpeta.length + 1) : archivo; +} From 8458f9a2f2bd018e179a0931ed77648a7b927b24 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 18:43:11 -0400 Subject: [PATCH 19/28] =?UTF-8?q?feat:=20skill=20para=20crear=20los=20issu?= =?UTF-8?q?es=20de=20traducci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El agrupado ya lo calcula check-translations --issues. Lo que un script no acierta es nombrar cada lote y escribir sus criterios, y de eso trata el skill. La convención de títulos sale del historial, no de suposiciones. Cuatro patrones: Traducir - Guías de una carpeta (Guías de Errores, de SSR) Traducir - Guía de un documento (Guía de Seguridad, Zoneless) Traducir - Tutorial un tutorial (Tutorial Signals) Traducir - nombre propio (Press Kit, Roadmap) Con prefijo de versión cuando las páginas las trae un bump: «[Angular 22.1] Traducir guías de Signal Forms». La regla para nombrar la sección es la misma del glosario: descriptor en español, nombre de producto o API en inglés. Y hay una referencia mejor que cualquier regla escrita — navigation-entries/index.ts, donde esos nombres ya están decididos. De ahí salen los ejemplos: «Enciclopedia de Errores» traduce el descriptor, «Estado dependiente con linkedSignal» deja la API en inglés. Los criterios de aceptación son verificables con un comando, a propósito. «La traducción suena natural» no se puede marcar como cumplido sin discutir; «lint-glossary no reporta problemas» sí. Y son distintos para traducir que para actualizar, porque el trabajo es otro. Dos salvaguardas que importan: - Comprobar qué issues existen ANTES de crear nada. El repo los mantiene a mano y ya cubre buena parte del pendiente; duplicarlos es peor que no crearlos. - Enseñar los borradores y esperar confirmación antes de ejecutar gh issue create. Es una acción visible para toda la comunidad y un lote mal agrupado hay que cerrarlo a mano después. --- .claude/skills/crear-issues-traduccion.md | 165 ++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 .claude/skills/crear-issues-traduccion.md diff --git a/.claude/skills/crear-issues-traduccion.md b/.claude/skills/crear-issues-traduccion.md new file mode 100644 index 00000000..e226458d --- /dev/null +++ b/.claude/skills/crear-issues-traduccion.md @@ -0,0 +1,165 @@ +--- +name: crear-issues-traduccion +description: Crear los issues de traducción del pendiente, con título, etiquetas y criterios de aceptación según la convención del repo +--- + +# Crear issues de traducción + +Convierte el pendiente detectado en issues de GitHub bien formados. El agrupado +lo calcula la herramienta; lo que aporta este skill es lo que un script no +acierta: **nombrar** cada lote y escribir sus criterios de aceptación. + +## Paso 1 — Obtener el pendiente agrupado + +```shell +npm run check-translations -- --issues +``` + +Devuelve lotes ya agrupados por carpeta, subiendo de nivel cuando una carpeta no +reúne suficientes archivos. Cada lote sale marcado con `← renombra esto`: ese es +tu trabajo. + +Si necesitas los datos crudos —para contar, filtrar o ver los diffs—: + +```shell +npm run check-translations -- --json +``` + +## Paso 2 — Comprobar qué ya existe + +**Antes de crear nada.** El repo mantiene estos issues a mano y duplicarlos es +peor que no crearlos: + +```shell +gh issue list --state open --limit 100 --search "traducir OR actualizar" +``` + +Si un lote ya tiene issue, no lo abras de nuevo. Si el issue existe pero le +faltan archivos que ahora sí detectamos, **coméntalo** en vez de abrir otro. + +## Paso 3 — Componer el título + +Cuatro patrones, todos sacados del historial del repo: + +| Situación | Patrón | Ejemplos reales | +| --- | --- | --- | +| Una carpeta de guías | `Traducir - Guías de ` | Guías de Errores · Guías de SSR · Guías de Componentes | +| Un solo documento | `Traducir - Guía de ` | Guía de Seguridad · Guía de Tailwind · Guía Zoneless | +| Un tutorial | `Traducir - Tutorial ` | Tutorial Signals · Tutorial Learn Angular | +| Página con nombre propio | `Traducir - ` | Press Kit · Roadmap · Releases | + +Para traducciones desactualizadas, cambia el verbo: `Actualizar - Pasos del +tutorial Learn Angular`. + +Si las páginas las trae una versión nueva de Angular, antepón la versión: +`[Angular 22.1] Traducir guías de Signal Forms`. + +### Cómo nombrar la sección + +La misma regla del glosario: **el descriptor va en español, el nombre de +producto o API se queda en inglés.** + +- `reference/errors` → **Guías de Errores** +- `guide/forms/signals` → **Guías de Signal Forms** (no «Formularios de Señales») +- `guide/di` → **Guías de Inyección de Dependencias** +- `tools/devtools` → **Guías de Devtools** +- `guide/zoneless` → **Guía Zoneless** + +Ante la duda, **mira cómo se llama esa sección en el menú**: ahí ya está +traducida y decidida por alguien. + +```shell +grep -n "label:" adev-es/src/app/routing/navigation-entries/index.ts | grep -i +``` + +Ese archivo es la mejor referencia de estilo que hay. Por ejemplo: + +| En el menú | Qué enseña | +| --- | --- | +| `Enciclopedia de Errores` | el descriptor se traduce | +| `Inyección de Dependencias` | término establecido, en español | +| `Estado dependiente con linkedSignal` | el nombre de la API se queda en inglés | + +Nunca uses la ruta como título. `reference/errors` es el dato de entrada, no el +nombre. + +## Paso 4 — Elegir etiquetas + +- `docs-translation` — **siempre**. El 17 % de los issues del repo no la tiene, y + por eso las búsquedas por etiqueta no son fiables. +- `good first issue` — solo si el lote es pequeño (1–3 archivos), sin bloques de + código complejos y sin terminología nueva. +- `help wanted` — cuando el lote es grande y conviene repartirlo. + +No inventes etiquetas: usa las que existen (`gh label list`). + +## Paso 5 — Escribir el cuerpo + +Estructura fija: + +```markdown + + +## Archivos + +- [ ] `archivo.md` +- [ ] `otro.md` + +## Criterios de aceptación + +- [ ] Cada archivo tiene su `.en.md` con el original en inglés +- [ ] `npm run lint-glossary` no reporta problemas en los archivos tocados +- [ ] `npm run check-translations` ya no los lista +- [ ] Los prefijos de alerta (`NOTE:`, `TIP:`, `IMPORTANT:`…) siguen en inglés +- [ ] `.md` y `.en.md` van en el mismo commit +``` + +Para un lote de **actualización** los criterios cambian, porque el trabajo es otro: + +```markdown +## Criterios de aceptación + +- [ ] Solo se tocaron los bloques que cambiaron en el original +- [ ] `npm run verify-translation -- ` pasa en cada archivo +- [ ] `npm run check-translations` ya no los lista +- [ ] `.md` y `.en.md` van en el mismo commit +``` + +### Sobre los criterios + +Son verificables con un comando, a propósito. Un criterio como «la traducción +suena natural» no se puede marcar como cumplido sin discutir; «`lint-glossary` +no reporta problemas» sí. + +En los lotes de actualización, incluye el conteo de líneas por archivo que da la +herramienta: distingue el trabajo de dos minutos del de media hora y ayuda a +repartir. + +## Paso 6 — Crear el issue + +```shell +gh issue create \ + --title "Traducir - Guías de Errores" \ + --label docs-translation \ + --body-file cuerpo.md +``` + +Usa `--body-file`: pasar markdown largo con `--body` se rompe con las comillas y +los saltos de línea. + +> [!IMPORTANT] +> Crear issues es una acción visible para toda la comunidad. **Enseña los +> borradores y espera confirmación antes de ejecutar `gh issue create`**, incluso +> si te pidieron crearlos. Un lote mal agrupado o mal nombrado hay que cerrarlo a +> mano después. + +## Qué no hacer + +- **No abrir un issue por archivo.** El repo agrupa por sección; 31 issues para + 31 archivos es ruido que nadie atiende. +- **No mezclar traducir con actualizar** en el mismo issue: el procedimiento es + distinto y los criterios de aceptación también. +- **No incluir archivos huérfanos.** Si `check-translations` los lista como + huérfanos, esas páginas ya no existen en el original: hay que borrarlas, no + traducirlas. +- **No usar la ruta como título.** From e0f1878125e076a264a4289085e265a6f72e83fd Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 18:47:02 -0400 Subject: [PATCH 20/28] fix: los skills del proyecto no se cargaban por el formato MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code espera .claude/skills//SKILL.md, no archivos .md sueltos. Con el formato viejo ninguno de los cuatro se registraba, así que translate-angular-docs y batch-translate llevaban tiempo sin poder invocarse pese a estar escritos y mantenidos. Se detectó al intentar usar el skill nuevo: comparando con los que sí funcionan en ~/.claude/skills, la única diferencia era la ubicación. El frontmatter ya era correcto. Tras mover, tres de los cuatro se registran. translate-angular-docs sigue sin aparecer, pero por otra causa y ajena al repo: está en "off" dentro de skillOverrides en la configuración global del usuario. Se puede seguir invocando a mano; solo está desactivado para invocación por el modelo. También se corrigen los enlaces entre skills, que apuntaban al archivo plano. --- .../skills/{batch-translate.md => batch-translate/SKILL.md} | 0 .../SKILL.md} | 0 .../SKILL.md} | 0 .../skills/{translate-delta.md => translate-delta/SKILL.md} | 4 ++-- 4 files changed, 2 insertions(+), 2 deletions(-) rename .claude/skills/{batch-translate.md => batch-translate/SKILL.md} (100%) rename .claude/skills/{crear-issues-traduccion.md => crear-issues-traduccion/SKILL.md} (100%) rename .claude/skills/{translate-angular-docs.md => translate-angular-docs/SKILL.md} (100%) rename .claude/skills/{translate-delta.md => translate-delta/SKILL.md} (96%) diff --git a/.claude/skills/batch-translate.md b/.claude/skills/batch-translate/SKILL.md similarity index 100% rename from .claude/skills/batch-translate.md rename to .claude/skills/batch-translate/SKILL.md diff --git a/.claude/skills/crear-issues-traduccion.md b/.claude/skills/crear-issues-traduccion/SKILL.md similarity index 100% rename from .claude/skills/crear-issues-traduccion.md rename to .claude/skills/crear-issues-traduccion/SKILL.md diff --git a/.claude/skills/translate-angular-docs.md b/.claude/skills/translate-angular-docs/SKILL.md similarity index 100% rename from .claude/skills/translate-angular-docs.md rename to .claude/skills/translate-angular-docs/SKILL.md diff --git a/.claude/skills/translate-delta.md b/.claude/skills/translate-delta/SKILL.md similarity index 96% rename from .claude/skills/translate-delta.md rename to .claude/skills/translate-delta/SKILL.md index a7685c9b..924f6da0 100644 --- a/.claude/skills/translate-delta.md +++ b/.claude/skills/translate-delta/SKILL.md @@ -10,7 +10,7 @@ Cuando el original en inglés cambia, la traducción española **no se rehace**: acumulado que retraducir destruiría. Este skill es para archivos **ya traducidos que quedaron desactualizados**. Para traducir un -archivo desde cero usa [`translate-angular-docs`](./translate-angular-docs.md). +archivo desde cero usa [`translate-angular-docs`](../translate-angular-docs/SKILL.md). ## El principio: leer mucho, escribir poco @@ -96,7 +96,7 @@ desactualizado para siempre: es exactamente el origen del falso positivo de `sel ## Reglas de edición -Aplica el glosario de [`translate-angular-docs`](./translate-angular-docs.md), más estas +Aplica el glosario de [`translate-angular-docs`](../translate-angular-docs/SKILL.md), más estas específicas del delta: | Regla | Por qué | From b678079877f54bf03b2ac158b4bcb62e6d5935de Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 18:58:09 -0400 Subject: [PATCH 21/28] fix: enmascarar atributos HTML y definiciones de enlace en el linter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salió de revisar si el glosario del skill (217 filas) debería pasar a glosario.yml (18 reglas). Midiendo qué pasaría, aparecieron términos ingleses "colados en prosa" que en realidad venían de rutas dentro de atributos: href="tools/cli/deployment", path="src/overview/basic/app.ts". No es un problema con las reglas de hoy —solo desaparece 1 hallazgo real, un `librería-` dentro de un atributo— pero sí lo sería con cualquier regla futura sobre una palabra que aparezca en una ruta. El linter ya enmascaraba código, enlaces markdown y anchors; los atributos y las definiciones de enlace de referencia eran el hueco que quedaba. Con tests que comprueban lo que importa: que el texto visible junto a un atributo se siga revisando. --- tools/glossary.test.mjs | 15 +++++++++++++++ tools/lib/glossary.mjs | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tools/glossary.test.mjs b/tools/glossary.test.mjs index 33dffac7..64cfc89e 100644 --- a/tools/glossary.test.mjs +++ b/tools/glossary.test.mjs @@ -94,3 +94,18 @@ test('ninguna regla marca su propia forma correcta', () => { assert.equal(found.length, 0, `la regla ${r.pattern} marca su propio expected "${r.expected}"`); } }); + +// Los atributos HTML llevan rutas e identificadores: si no se enmascaran, +// cualquier regla sobre una palabra que aparezca en una ruta dispara sola. +test('no aplica dentro de atributos HTML', () => { + assert.deepEqual(hits(''), []); + assert.deepEqual(hits('enlace'), []); +}); + +test('sí aplica al texto visible junto a un atributo', () => { + assert.deepEqual(hits('esta librería es útil'), ['biblioteca']); +}); + +test('no aplica en definiciones de enlace de referencia', () => { + assert.deepEqual(hits('[GuiaX]: tools/cli/librería-y "Título"'), []); +}); diff --git a/tools/lib/glossary.mjs b/tools/lib/glossary.mjs index 85d392ba..e79db160 100644 --- a/tools/lib/glossary.mjs +++ b/tools/lib/glossary.mjs @@ -14,7 +14,12 @@ export function mask(text) { .replace(/]*\/>/g, blank) .replace(/`[^`\n]*`/g, blank) // código en línea .replace(/\]\([^)\n]*\)/g, blank) // destinos de enlaces - .replace(/\{#[^}\n]*\}/g, blank); // anchors explícitos + .replace(/\{#[^}\n]*\}/g, blank) // anchors explícitos + // Valores de atributos HTML: casi siempre rutas o identificadores + // (href="tools/cli/deployment", path="src/overview/app.ts"). Sin esto, una + // regla sobre cualquier palabra que aparezca en una ruta dispara sola. + .replace(/(\w+)=("[^"\n]*"|'[^'\n]*')/g, blank) + .replace(/^\s*\[[^\]\n]+\]:\s*\S+/gm, blank); // definiciones de enlace } /** From 296f741264f44ef2ed54ab527ea125a6aee6a1a2 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 18:58:56 -0400 Subject: [PATCH 22/28] fix: no enmascarar los atributos que llevan prosa traducible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El commit anterior enmascaraba TODOS los atributos y con eso introdujo un falso negativo: dejaba de revisarse, cuando ese título se renderiza y sí debe estar traducido. Se vio al mirar qué hallazgo había desaparecido — el único que quitó el cambio era precisamente uno legítimo. Ahora solo se enmascaran los atributos que llevan rutas o identificadores (href, src, path, region, id, class, language…), y quedan fuera title, header, alt y label. El criterio es el mismo que ya sigue el corpus: 237 de 237 `` están traducidos. Los hallazgos vuelven a 748, que era el número correcto. --- tools/glossary.test.mjs | 12 ++++++++++-- tools/lib/glossary.mjs | 15 +++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tools/glossary.test.mjs b/tools/glossary.test.mjs index 64cfc89e..a3ec2bb6 100644 --- a/tools/glossary.test.mjs +++ b/tools/glossary.test.mjs @@ -97,9 +97,17 @@ test('ninguna regla marca su propia forma correcta', () => { // Los atributos HTML llevan rutas e identificadores: si no se enmascaran, // cualquier regla sobre una palabra que aparezca en una ruta dispara sola. -test('no aplica dentro de atributos HTML', () => { - assert.deepEqual(hits(''), []); +test('no aplica dentro de atributos de ruta', () => { + assert.deepEqual(hits(''), []); assert.deepEqual(hits('enlace'), []); + assert.deepEqual(hits(''), []); +}); + +// title, header y label llevan prosa que sí se traduce: en el corpus hay +// 237 de 237 `` traducidos. +test('SÍ aplica en los atributos que llevan prosa visible', () => { + assert.deepEqual(hits(''), ['biblioteca']); + assert.deepEqual(hits(''), ['biblioteca']); }); test('sí aplica al texto visible junto a un atributo', () => { diff --git a/tools/lib/glossary.mjs b/tools/lib/glossary.mjs index e79db160..88561f62 100644 --- a/tools/lib/glossary.mjs +++ b/tools/lib/glossary.mjs @@ -15,10 +15,17 @@ export function mask(text) { .replace(/`[^`\n]*`/g, blank) // código en línea .replace(/\]\([^)\n]*\)/g, blank) // destinos de enlaces .replace(/\{#[^}\n]*\}/g, blank) // anchors explícitos - // Valores de atributos HTML: casi siempre rutas o identificadores - // (href="tools/cli/deployment", path="src/overview/app.ts"). Sin esto, una - // regla sobre cualquier palabra que aparezca en una ruta dispara sola. - .replace(/(\w+)=("[^"\n]*"|'[^'\n]*')/g, blank) + // Atributos que llevan rutas o identificadores: href="tools/cli/deployment", + // path="src/overview/app.ts". Sin esto, una regla sobre cualquier palabra que + // aparezca en una ruta dispara sola. + // + // Se excluyen a propósito los que llevan PROSA VISIBLE —title, header, alt, + // label—: esos se traducen y deben revisarse. En el corpus hay 237 de 237 + // `` traducidos. + .replace( + /\b(href|src|path|region|visibleRegion|preview|id|class|language|highlight)=("[^"\n]*"|'[^'\n]*')/gi, + blank + ) .replace(/^\s*\[[^\]\n]+\]:\s*\S+/gm, blank); // definiciones de enlace } From f746bb92d5d8d396eebdadbb8cd1364770683120 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 19:08:51 -0400 Subject: [PATCH 23/28] =?UTF-8?q?docs:=20el=20c=C3=B3mo=20traducir=20vive?= =?UTF-8?q?=20en=20CONTRIBUTING,=20no=20repetido=20en=20cada=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los issues llevaban la metodología dentro: qué es un delta, los comandos, los criterios. Eso no cambia entre issues, así que repetirlo en diez es ruido y obliga a editar diez sitios cuando cambie. En angular-ja el cuerpo del issue es mínimo justamente por esto: el cómo vive en la guía. Se añaden dos secciones con ancla para poder enlazarlas: - #respaldo — por qué el .en.md no es opcional y cuándo NO crearlo. Corrige además el consejo anterior, que decía que las traducciones parciales no necesitan respaldo: es exactamente lo que dejó translation-files.md sin protección durante meses, a un update-origin de perderse. - #actualizar — el flujo de delta, con las tres respuestas que puede dar plan-translation y qué hacer con cada una. - #antes-del-pr — las dos comprobaciones y el porqué de commitear .md y .en.md juntos. La tabla de comandos incorpora las seis herramientas nuevas, que no estaban. Los diez issues abiertos quedan recortados a lo que de verdad es suyo: de dónde salen las páginas y qué archivos son. El resto se enlaza. --- CONTRIBUTING.md | 91 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67fc1e07..7c55ac3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -224,30 +224,85 @@ El resultado de la compilación se genera en la carpeta `build/dist`. ## Directrices para la Traducción -### Guarda el original como archivo `.en.md` +### Guarda el original como archivo `.en.md` {#respaldo} -Para facilitar la gestión de cambios (diff) después de actualizar el submódulo `origin`: +Cada traducción vive junto a un respaldo del original: -1. **Para traducciones completas:** - - Copia el archivo original `xx.md` a `xx.en.md` (versión en inglés) - - Sobrescribe `xx.md` con tu traducción al español +| Archivo | Qué contiene | +| --- | --- | +| `guide/x.md` | la traducción al español | +| `guide/x.en.md` | el inglés a partir del cual se tradujo | -2. **Para traducciones parciales:** - - No necesitas crear el archivo `xx.en.md` - - Trabaja directamente sobre `xx.md` +Ese respaldo **no es opcional ni decorativo**: es lo único que le dice a +`update-origin` que ese archivo ya está traducido. Sin él, la próxima +sincronización con Angular lo trata como pendiente y le escribe el inglés +encima, sin aviso y en medio de un commit de cientos de archivos. -**Ejemplo:** +Además es lo que permite detectar después qué cambió en el original, comparando +el respaldo de entonces con el de ahora. -```bash -# Traducir guide/components.md -cd adev-es/src/content/guide +#### Al traducir una página nueva + +```shell +# 1. Comprueba primero si ya tiene respaldo +ls adev-es/src/content/.en.md + +# 2. Si NO existe, créalo antes de tocar nada +cp adev-es/src/content/.md adev-es/src/content/.en.md + +# 3. Ahora traduce el .md +``` + +> [!WARNING] +> Si el `.en.md` **ya existe**, no ejecutes ese `cp`. Escribirías español encima +> del respaldo y se perdería el registro de qué inglés se tradujo, dejando el +> archivo fuera de la detección para siempre. + +Esto vale también para traducciones parciales: un archivo a medio traducir +necesita su respaldo igual que uno completo. + +### Actualizar una traducción desactualizada {#actualizar} -# Copiar el original -cp components.md components.en.md +Cuando Angular cambia una página que ya estaba traducida, **no se retraduce**: +se aplica al español únicamente el cambio que ocurrió en inglés. El resto de la +página ya está bien, y suele tener correcciones acumuladas que una retraducción +tiraría. -# Ahora edita components.md con la traducción en español +```shell +# Qué está desactualizado y desde cuándo +npm run check-translations + +# Qué bloques hay que tocar, con su texto exacto +npm run plan-translation -- .md + +# Después de editar: comprueba que no se tocó nada más +npm run verify-translation -- .md ``` +`plan-translation` te dice a qué te enfrentas: + +| Respuesta | Qué significa | +| --- | --- | +| `listo` | son cambios acotados; edita solo los bloques que lista | +| `manual` | es una reestructuración, no un delta: traduce esa sección entera, con la traducción actual delante como referencia de estilo | +| `solo-ruido` | el cambio no afecta al español (reformateo, URLs); no hay nada que hacer | + +`verify-translation` comprueba cuatro cosas: que las líneas modificadas caigan +dentro de los bloques previstos, que la estructura siga correspondiendo al +original, que los enlaces internos resuelvan, y la terminología de lo que +tocaste. + +### Antes de abrir el PR {#antes-del-pr} + +```shell +npm run lint-glossary -- # terminología +npm run check-translations # que el archivo ya no aparezca pendiente +``` + +Y commitea **`.md` y `.en.md` en el mismo commit**. Separarlos deja el archivo +marcado como desactualizado de forma permanente, porque la detección busca el +commit donde se tocaron ambos. + ### Alinear saltos de línea Siempre que sea posible, mantén el mismo número de líneas entre el archivo original y la traducción. Esto facilita: @@ -324,6 +379,12 @@ export class HeroComponent { | `npm start -- --init` | Reinicializa el build y luego inicia servidor | | `npm run build` | Compila el proyecto para producción | | `npm run update-origin` | Actualiza el submódulo origin a la última versión | +| `npm run check-translations` | Qué falta por traducir y qué se desactualizó | +| `npm run check-translations -- --issues` | Lo mismo, agrupado en lotes del tamaño de un issue | +| `npm run lint-glossary` | Revisa la terminología contra `glosario.yml` | +| `npm run plan-translation` | Qué bloques tocar en una traducción desactualizada | +| `npm run verify-translation` | Comprueba que solo se tocó lo previsto | +| `npm test` | Tests de las herramientas | | `npm run deploy:staging` | Despliega a Firebase Hosting (staging) | | `npm run deploy:prod` | Despliega a Firebase Hosting (producción) | From 1d0e264852451bea1d667ab8e845677cb2f9f9db Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 19:10:24 -0400 Subject: [PATCH 24/28] feat: plantilla de pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los criterios de aceptación se movieron aquí desde los issues: nadie marca casillas en un issue, pero sí las revisa al mergear. El tamaño lo decide la práctica actual: los diez PRs de traducción del historial tienen cuerpos de ~10 caracteres, literalmente «Fixes #171». Una plantilla larga se borraría, así que son cuatro comprobaciones y un campo de notas — siete líneas visibles, dos más que la de angular-ja. Las cuatro son verificables y cada una responde a un fallo real observado: - .md y .en.md en el mismo commit: separarlos deja el archivo marcado como desactualizado de forma permanente, porque la detección busca el commit que tocó ambos. Es el origen del falso positivo de selectors.md. - Prefijos de alerta en inglés: son claves del tokenizer de adev, no prosa. Traducirlos hace que el aviso salga como párrafo plano; hay 425 así. - Los dos comandos de la guía. El tercero dice «en lo que toqué» a propósito: el repo arrastra 748 hallazgos de glosario, así que exigir el linter limpio sobre todo el corpus sería imposible de cumplir. Los comentarios HTML explican el porqué de cada una sin ocupar sitio en el PR renderizado. --- .github/pull_request_template.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..3ef3cd66 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,28 @@ +Fixes # + + + +## Comprobaciones + +- [ ] `.md` y `.en.md` van en el **mismo commit** +- [ ] Los prefijos de alerta (`NOTE:`, `TIP:`, `IMPORTANT:`, `HELPFUL:`…) siguen en inglés +- [ ] `npm run lint-glossary -- ` no reporta problemas en lo que toqué +- [ ] `npm run check-translations` ya no lista estos archivos como pendientes + + + +### Notas + + From 2cad41c46c496fee20f23482c0e7b7aab26fadc2 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 19:22:57 -0400 Subject: [PATCH 25/28] =?UTF-8?q?fix:=20detectar=20por=20comparaci=C3=B3n?= =?UTF-8?q?=20con=20el=20original,=20no=20por=20idioma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un archivo sin respaldo puede estar pendiente o ya adaptado, y distinguirlo importa: lo segundo es pérdida de trabajo inminente. Se hacía con detección de idioma, que no sabe clasificar un archivo sin prosa. links.ts pasaba por pendiente cuando en realidad ya está adaptado —apunta al GitHub de Angular Hispano y a su Discord— y le falta el .en.ts. El próximo update-origin lo habría devuelto a los enlaces de Angular. La lógica de copia es agnóstica a la extensión, así que esto no era exclusivo de .md. Comparar contra el original es exacto y vale para cualquier extensión. Sobre los 38 que se reportaban como pendientes: 37 son idénticos al original —pendientes de verdad— y 1 difiere, que es precisamente links.ts. La detección de idioma se conserva como respaldo para cuando el submódulo no está inicializado o se audita otra rama. También se borra adev-es/src/assets/textures/construir-para-todos.png: una textura de la portada traducida en abril de 2024 que quedó huérfana cuando upstream rediseñó la home. Verificado que no existe upstream y que nada la referencia. --- .../assets/textures/construir-para-todos.png | Bin 10245 -> 0 bytes tools/check-translations.mjs | 35 ++++++++++++------ 2 files changed, 23 insertions(+), 12 deletions(-) delete mode 100644 adev-es/src/assets/textures/construir-para-todos.png diff --git a/adev-es/src/assets/textures/construir-para-todos.png b/adev-es/src/assets/textures/construir-para-todos.png deleted file mode 100644 index 7950967d0389d424d202a55b7e847bf0bd9f08de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10245 zcmb_?2Ut{D)@>0aDOr)A+kFC;@)#l-E+>~Yp=C-h27LpB_^UJLLd;tSJjlX5eO_a ze3m4@ga69*+R(%Q2wl|fx+4%I&6t0&61Yj|5D458yIXo5dg?bMEs##UW|l~ED_(CW z7Z{B|NPA1dM<*)}Ge&PGM`w3QZyAo?BP8K7<}x3LED_^B|B-gHw3gIXQu*gl_)CVv z*2BX^l8^7+y?ea(gm{r|HhcmS5)yp;f_#F4JTQXC{l2q@nKzHKI}6P8*Qu1O+%4Sf zTs-WM&WxC;&CHRW9x@ype~kLaD_L6n@ii`k8FK=mS zA?ab~;b`@@7r)}@@wY3Ec9@k&I+{7#$Z&Y`SXx<|c{+M<$SNY8-96kq?c5k$%-qZv zJ&=}2cSasY0ck$WTRPhPX`0_x{yHQx&G}6>=cdSXs&n@(T*{@C)+@3QF_+?baVBDgDnI%HAGV;R#WG5n*v_3m##9 z5py143o(8kb0Kj-9)59gabY1#VJlI7;Xmg3$0PqSlZrV^BqAsxC@LZ$AS@szAtKKI z$1}fg{>LM?ke(K{m}SZS`%V9S?cc-yH^=i1Eu4rkIkJsEE7+rqpGkLT#hFsg&pg5MXY$LlM z?M+rzR;G62#!`Kv7UwDh9v+^Hi;L;A)o;}mIQj$SE>z~3e#a8*n$OSJ*x1a^&leBB zEhzX1*TR}YNLfThF<(*8f3rO#qOML>T1JNEhcrED1QpK?{@f%9zp zVW6J6Dn@7+V->0VoU{6vC70F-STOFqswk3;hB^~(F3?67(qWo6|S^xHc{Uh)x~8409LOFjg}*Iuiqi83)V zX5Dt4nwomDx9441S(%lcoqfA-Kik~SE-XF$g35jrotR{n$a2%>To3xuuLnoFA5PA3 z73JqE*xK6SmxcF#*&}Q14ng##SDH3phhg7fB%3_`b;xvk=YYcRwiQIm3m4}POj~fz>n#8w(Dz)MIT<&)YK%T zYVOC5eDIUoS)G{Z>&m*xxk`TKYKiN#%8OMjM7iS^Yu=`v$6ECj@b=LIcB;xw@>n)x;E8ikcey#eURs&k6zlSh9GJPiJvqx6# zwaol{dS)iDNA@sMi=K`y;&69WsNG8tF}*{8X5xdU01$J)8Zr!<)Q(Ag9C@4rxU0vDDP0-%nKJ@b>7TGob zf`W}5Nqn#DO^uD|WsmnP0)vB@1qCUvAInckNJ>iTReSNUYoxd3lh>&O2qy9xg|nze ztDykHqFQ4ZgxZrX-+eOl_D+t;hezTrlZaqa2HqXX^Sp-s6%}H~`*RHF?U^4x(BA9j zKCsh!^EolB%f|lM^XDyEB5B&%+bIls;GpnH=`EjCL&acX!eU}#(nFh{vWOHF z6;;sGBqe2#JhatMfs8oyQFdu89c^rZxCKXeJQKAAV?l9h_rFto~jj+vSHiI&Z1saHg9IUe)>R9G5^G|O49QcT zo}S%KNOj1)VvAOisn*)ULXsptlNEp6GT!B1kr2M}fRnFDGvEN++}$yejouy{8baS& zHGY|!+c-R&=-#Vva{SA6VUWuQA}4*}>DunDs-7OTYozRhEj1*T_NO3KPil9Ku; zgoE=y&HYVrLN3=E8ItEtojT>>>G^}T-ai*<4msc0*vPu0OiWCiF5!;%`0?ZP4__b& zLc_wY7#dyxFjk5tG_ZAaJcEOSGj*!cR)Q6v>88GZdtM1zaZ{{sC}?KF5Di!01Ce(>!bJ~$ovr|jzSTvLVD zlX&-*D<&*cx3-tQP=5dZ9n}iuFz6&HDXF#2_mGT=Dwx;ky_zo{0&{W#-fN~Q-A>ob zkrTY`%=jWABA8dCWWN!scW-^_BW!!p!U60t!Rgaa-y0$LEn3bjeXXJdXw=T+hAIO) z4qL6|SUW#&tEZ>eHYm%$zz|6zq_S1lbwVTV!npntGQhCPO*xm&O<7eH-__NX!p1J% z$E2{ZFtSX^Q16{3AtX7I_Hb`6W8dm_m)HcEpCbc#}=?oNJuD7kXP0aGDDYVCW5cd^jEQ%sOS{brb?IZ z*c%%g)L435TR#Uv<~QS;hYJio>QjyHL_yAw4+M#9ZvE6TeD6UB3cT{(Dm^;-$rFXQ z1$UfyW;;_9w<_wQUQQ(U%vsEJX8=r^7YsN)Nx=PS{Ei@-Nimr5(xq!Jm>GY5!N$g3 z8@gjc9+Q%i($Lx(;=KeQCjRJ19KsA?;?Dnl& zrXR!4&3`UpI73VMlPcVY9HQ1-7N|B z&H${z@DqL!k)U_)1cil#F^RSGtvYFs4wzEtkUl2yB+(zd*O{fIrF%2n8EW>K<=c#8 z^-WDU?Ck7-A38YJIU;6ZKq5YAUmgjM2+TR;5hfSirvp+TZ5qlaZ4@j*L8mi;G)7F_HOQ&gWn& zZ|#I9vmymZe%NDnLp!RM05FY$vY9=fioV>n?XhvRwXMHn;>$+CiQ(87>cP!@8tP|r zT{fV%n3&koScT@x8Z??5IHnRHKO7P8$iSix$EnnM%&=Kr%le%eQc2_)92%!%m2TZS z`y?PhnZoDk_(XdG_xIUZJRKdKua&Mi04AtWXQ;Il6cm$la~#^vrlts>o*G&SJa)_I zO~7eNBl;gdeo%|pKMNho2jvB2t*xyML+)R^ctJe}5`x6q+B#DC(5TwWYUo+a1&Jqw zlCTjkL0hz%_q|YVE&@A&$grD2j-%SHt^%-) zXpkEq2FOp1j1_NRy*pBUk8id+qhlod$<{H@q5v8p>&d}9o#J=z;%y7rQr_+P$j(i^ zDYx!_d9`_?xA!L6IjO*)f*GoXR`>LJ=7d_1_Jqpgh*UAhrj#jVA81x~-#%_Bm;LTOSGnuY1s z=lfVx?iU%?Qa>Va6&4k}wsqy)`SX+=SFLYqdzS)49q&z(MqZRk6nQi0YYN#Z*ySxn z32cCs=PjG#FU@ouY2axf3ePWA|JdBud+))ebvllX;2I-YD6uUwpl1l++11X$>7}Jk z`;CVTEjQrHFbY*=A9SeRXR$isF*mG!u~?pK2W@dIsKRH~09v6F%C5yJj#Zak;lT0x zD&#!oZ?^2N?<|i+KInRU11P%Ssdj>g0(Nmch?Jc0%0{Z2{r$6=3XEiAln<~Oy+lRn zWgY}d-CGGQXtF+JBQ7j06@H=9|MlwyP&(c_OEiF4#lRHBrKCEfS)84qF0vLfEG(7o z3IS0G9l*X-?rC>$aG?Dy2CB!}WW(C#rXrBKmGRmRNu9(XKnAtimar&U--CL{d-xNR zTk%hyhK;r!J^==ywPsT1lQK{j2FTD%7!nspKK-7x#9&i|m5Gc%e8@rN%9SgXj7LuP z4h|Ag2^$-3m{QQ5b~rUwdZyjBWqh0-2y*LmYfM~vdgRB5^A?@S0_n`QL9zm(A&gfC zY}M5cuhc&JmCA{}YFPW=+{(&Iq?_oW7-8Y*>s4;6<1$AFi$$nxG$dM3ZEY>T)U}dj zX?Auz)fjrGO?g_;@bGZSrSWCuhjP;pVd6v8ekZb*xVdSpOJ*5kLC6+Acd)j`BPAsT zdvX$r)w$|N>qhsb0*&>zwzej}e}4?RqX|+nnfP@{ed78w)XVhlA{0u=&W>w3?KPkv zloeI>vK61d6 zmg@}O1`2g~W^T^nXa6h6LVF!7Jb6b+0Z|W6Pqa-B{nsjYd`Pgsr%!cWQ<@_5CV0jK z@hBW8$`b_$9e1Z_a~Rb>SRCqJqa{yfn1j0_^tLq%0Yz1CMT=i$`4eC?3x>> zK$@hKsIGZScXMwU(Ahb@bwan^_4E{h0#A0~!sXk;kD$hzQ3QRj2Kdzg3 zttP7JXLJY0#E`-&$Z2WAr>4wtPn`-Acc!DG3)2$5bm>z1`O76%yrx1iUbui$3XYDK zzkd4$I7#ZW^TlRwrFzxnXwP*x_5kWuX_eiZ9MesnBf7X}IOr4zcuF~NdTyT?=zV9^ z!N}M1^_dJSD=x;rB9%Mx2dglGwJ?E=V%-QlK$5-+N(P1)Ad_aG_dpveU%B$Qm$sjL zd4?#>Uqn=N*5g=}!58=9#fvKU)zyicI=jkclX$q4uisEtml(oH^)Y!VUpl*e-_Ue% zE}osOCsR%pYIk~idRAT@AuQ)IWCEm&brj=s0gK?a31%5~UfyW$_>z*6K|;kt5b5u7 zUjQ5skdn4SGAl2jC6ud{q*~@S!)SyMWI0yj__VaNv6-1MZEb2`V3_x2U1?^dVq;@_ zk@GAnn!gX-)y}4THm>~Eeg*K!Cfdh`q5#Xy8;P=~McO4xf8LUOU4D}Mu683)l%fCGWc+FfJcN}Gp=b*FqmIlLUL zK$n*%QuFtN2-DbI94?>}psmTy#_~J)B{t^;I-CW3GcX!TT+%rD-LPe3Wa7e#+vuXA zqSz!iVS-}hglu;S0`w6Op&07t6M`d;d1l;T&nZ@?S5`FAf6Cn7n5k_m2TLtF==AyU z0{6omC*1+2Pd6H@t>+0;CDNwvXq$$kQhjX#!S1SFt$V761aSciy{-m05%x3?cqZD| z;ObREppq10y`!h9WF?C59%)qd;nY(g?H2%NusVJ#!e7xvs9xyeJJ01k9f) zbcOmLv%=DkjwBSrE$|$V_UAIqq4cTG)&kKZld=G4vCv{1!+QXfn}CGGx;7>|o9Qj( z9{89}6euQQ@OLrx1%SmGP^XB3j@dQ#F5NG(i1PCC<>y2GJj`|rgL$ZuvHE&>-Gb?f zcHrot9rCIBvuQ}Z^S?5s@5c|wG%wflJB?5FpO%L`ScV+T}@0(z;2iX@yN-^$v8`mo7%rvdLsrn(GwU?sax9Pezc|}P{2~%ys=da%)sEd~}A@{(MXY`%m7=xUf=ALp{K5NRP9 z8xIGsN)^aB$kN^qetzJ(UV)%ZOiUc9^ObU#jev4sez|erwWcdb#wiI|Sy^E3{t*$U z0WD!3Q&UrasHM}5L8p~Iq_Mtx_bxQ8ZNpm{Ty(m)xHxK2M`|E34Rf1jj*gLR*Ap38 zSOVrde1U9vEsZpAg1cDT`}!4Fui$k~?N0b{T$0(@+1Z15qW*F2+&Si1O_i9uf`Xvx zYAIgB%6eK)3v+YkvinnD8KA1jKAapsjAW2|PX6iR^!nuD;%PWr;H$v+)Y9XC!$f;l z$v-qjB4a{B34+-LtvjiKuH*3;*T@4G2jesg0tD=2%Cf`~o1Q5sGR+UuWgb!ila6$g zl9CGMsd@jNc41+`DIQ0Ng9D$>q>k0g92yII`}^4-Wqf2m+_vt|paEzDgM*o$pA7pk z!p+T%!dC#|&5QsA?l7aAT&BQ0D4@91)Np`(Mm|1LAX*rG4HEsX%`!Mm5QqNY4`Ou2 zliPPcf!c|RBF^#IUKqj*)7I9$dFKv3D6YMOgYc-Di16@6sP(Qt+Rx?reB`f*UQbl!=Hh|)u#l+(6B461e%XSt*=QIECV7Ei9rzou85v!@ zkHE)W2ajoL4EaGB918$=R{M1*k!P-VLA|B+jclE6=;$~No8o=2#f34(;B5e1sA*{_ ziY_uR@R{4O1Y?6hXo7~@kl;vCVq)XaP{L5k!;@oQ=;ee_GYy=HX??nX`YOgX_5(IvIS67sdkzHPXej_v< znuoz14Ss3TgKi2h;HF<#mQh$M&g(a+Y}avGX2Vmi_KZ{Lje z&|#60mdiqLUg}gMC(!3>7U+V2fxZNcFokv-6ru8){ud=ZLPb&pGJBHjr}Zfc1iVC@ zMw{&~IjHm_zxMX}gSZMKmhb54QB_h3Z1yU@U@|l`ghJy@ReJoSz{bJ(SjRvg!=dHx zG*Oq4%>N7C!JP8;?tC8(6%|!+dAapDQ;0lMPDRu@YJYzpsHM5*9-LwVcX3%+!kde1 z92`vp13K1G-zOUa*0#2m)*Kh%0snx2fX+j0LR{>nF-(Bi_mB4&<}Zb zylhkj?LXZjfPn9-t3CF{r&4eDtxk0~6rhDX7j8L@m8<7^hQK1hM-Fc$Wb7?< z#AxmGqHnMpfPdX}gNZ=aFy^%2a&F(qNGRmla8`}q@jgB@R%X+~3)qTGJ_HvP@v)fS zS$Yu(uhQ7ifCW}H%Asup(~kjq2Cao|3DPOx8)o+Q;S+u*8SaVeU=3P;;!PHJWp>!o zdbkNK6fp2jkB|1XdB!hay7UBm*}lZD@qj%R5abj%dbD(OR@croV`LLBRu}z#m!%N_ z;3#h0tvg8QTw;MMqiSlJ)qH%{#3bgTlvjOAOYqRMuRiLaXf8;&DNX%~p_jR2xFG0z z;4w_vR&6it2%CxxU6dqoJG+%b44cBZ)?*Y|R~KX1`7VV)C4*eT5EuYe3BEZyTU&W; zZHmsWE}DnUXe~$+K0ZDal(}TCW2g3x4gtG<)?ufd%6CpBtIh1^F=BXxz)B&|5 zz{RfdUdKK;+9U-pI1m6EeD}J6)_mkdor}AB6Espx!P*A%8*nBoKmVF{CMAakBecj% zc3#1srH2z$?y6HLH4~FebQ_6nzn+2Q#Dogud-Brf#zySm;NbbuGH!U4wS^&WY40@z zXh9`dC-e?pMdx1-v?RdvapG81F-;S|Hn0#I0nTF+5`ugWcLcCKK^}%OeT#ptd;#vR z?d$|6C7s73rH@?sR-FR}qnRnI3|%T9KrH4{$s!I^O<2FyzAHLH8hQIdbA76u71Kvt zG5ki=!%5=gYvMUHW>(<@rgrH3z+3dv7nC1BAjfj)2;ciaCFcU8u`Z ziE4z}ZwAT^FhoH^Lu0QS8slC3+!$pvHil{L_4fBG0HZE6t_=sZ8DTlb0B!TWd(Qy0 zL4@Xw5(rz_XUh6YVtR34aY4(x#%G@bPOAw#{9?b8M=eVepFR;rM@NH|sRUk;^F$rb zQF$reyU2?a(D=vn88H-qlz{}~?!Wto70%xjz>)>~?B%iVL`!`=0y>$&5Znj5tE5l> zG3~y}O6pIaK8=()B=Ng^?C+1KUQdmUWxP#+VUSH=G(h2}<~Q|U8@ml1L`X4aC{^%) z;PRPl(A?m(G=Osew5fMspa~-%0HlsCzAGt-OHK|2uM2IgdWlIT`(~R9x%pY5SKIgf z+-q)Pex1RL^O<4JYElOV;^FzI>${I?A3Z_r~2hR=XSO5S3 diff --git a/tools/check-translations.mjs b/tools/check-translations.mjs index 8d508161..48a946eb 100644 --- a/tools/check-translations.mjs +++ b/tools/check-translations.mjs @@ -142,16 +142,27 @@ try { if (orphanSet.has(md)) continue; if (!present.has(en)) { - // Sin `.en.md` hay dos situaciones muy distintas: que el archivo siga en - // inglés (normal), o que ya esté traducido y le falte el respaldo. Lo - // segundo es pérdida de trabajo inminente: el próximo `update-origin` lo - // trata como no traducido y le escribe inglés encima. - // El contenido se lee de la referencia auditada, no del disco: al mirar - // una rama, sus archivos nuevos no están en el árbol de trabajo. - const text = live - ? readFileSync(resolve(ROOT, md), 'utf8') - : (await $`git show ${ref}:${md}`.nothrow()).stdout; - (looksSpanish(text) ? unprotected : untranslated).push(md); + // Sin respaldo hay dos situaciones muy distintas: que el archivo siga + // igual que en el original (pendiente, lo normal), o que ya se haya + // adaptado y le falte el `.en.*`. Lo segundo es pérdida de trabajo + // inminente: el próximo `update-origin` lo trata como pendiente y le + // escribe encima, y eso vale para cualquier extensión, no solo .md. + // + // Comparar contra el original es exacto. La detección de idioma queda de + // respaldo para cuando el submódulo no está: no sabe clasificar un + // archivo sin prosa, y por eso links.ts —adaptado a los enlaces de + // Angular Hispano— pasaba por pendiente. + const original = resolve(ROOT, 'origin/adev', md.replace(`${ES_DIR}/`, '')); + let adaptado; + if (live && existsSync(original)) { + adaptado = readFileSync(original, 'utf8') !== readFileSync(resolve(ROOT, md), 'utf8'); + } else { + const text = live + ? readFileSync(resolve(ROOT, md), 'utf8') + : (await $`git show ${ref}:${md}`.nothrow()).stdout; + adaptado = looksSpanish(text); + } + (adaptado ? unprotected : untranslated).push(md); continue; } @@ -451,10 +462,10 @@ function report({ stale, untranslated, unprotected, orphans, unpaired, skipped, // Lo más urgente primero: esto es pérdida de trabajo, no deuda pendiente. if (unprotected.length) { - console.log(chalk.red.bold('Sin respaldo — el próximo update-origin las destruye:\n')); + console.log(chalk.red.bold('Sin respaldo — el próximo update-origin los destruye:\n')); for (const f of unprotected) { console.log(` ${short(f)}`); - console.log(chalk.dim(` está en español pero le falta su .en.md`)); + console.log(chalk.dim(` ya está adaptado pero le falta su ${enPathOf(short(f)).split('/').pop()}`)); console.log(chalk.dim(` arréglalo: git show :${f} > ${f.replace(/\.md$/, '.en.md')}\n`)); } } From c92acf81d6098f6993a8289c46d0c796c4341f80 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 19:28:38 -0400 Subject: [PATCH 26/28] fix: crear el respaldo de links.ts, que estaba desprotegido MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit links.ts está adaptado a Angular Hispano —nuestro GitHub, nuestro Discord— pero no tenía .en.ts. update-origin lo trataba como pendiente y le habría escrito encima los enlaces de Angular, deshaciendo la adaptación en medio de un commit de cientos de archivos. El respaldo se crea desde origin/adev, NO copiando el archivo actual: debe contener el inglés del que se partió, no la versión ya adaptada. Copiar el adaptado habría dejado un respaldo que no respalda nada. Con esto, «sin respaldo» baja a cero. Queda abierto el #205 para las divergencias menores que salieron al compararlos: la cabecera de licencia que se perdió, tres claves vacías que ninguna plantilla usa, y la indentación. --- adev-es/src/app/core/constants/links.en.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 adev-es/src/app/core/constants/links.en.ts diff --git a/adev-es/src/app/core/constants/links.en.ts b/adev-es/src/app/core/constants/links.en.ts new file mode 100644 index 00000000..ff058585 --- /dev/null +++ b/adev-es/src/app/core/constants/links.en.ts @@ -0,0 +1,17 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export const ANGULAR_LINKS = { + GITHUB: 'https://github.com/angular/angular', + X: 'https://x.com/angular', + MEDIUM: 'https://blog.angular.dev', + YOUTUBE: 'https://www.youtube.com/angular', + DISCORD: 'https://discord.gg/angular', + BLUESKY: 'https://bsky.app/profile/angular.dev', + STACKOVERFLOW: 'https://stackoverflow.com/questions/tagged/angular', +} as const; From e4229473ca4e0e8ffd5b582cd4294e3f402dc9ea Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 19:37:17 -0400 Subject: [PATCH 27/28] docs: AGENTS.md como instrucciones comunes a cualquier agente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sigue la convención que usa el propio angular/angular, que tiene su AGENTS.md en la raíz con frontmatter `trigger: always_on` —lo que esperan Windsurf y Antigravity— y mantiene el archivo corto enlazando a la documentación en vez de duplicarla. CLAUDE.md y GEMINI.md son punteros de tres líneas para las herramientas que buscan un nombre propio. No duplican nada: cualquier cambio va en AGENTS.md, y un test comprueba que sigan siendo punteros y no copias. El contenido es lo que un agente necesita saber y no puede deducir del código: que adev-es es una capa de traducción sobre un overlay, que el respaldo .en.* es lo único que protege una traducción de la próxima sincronización, y las cuatro reglas que rompen cosas si se ignoran —entre ellas los prefijos de alerta, que son claves del tokenizer y no prosa. Con tests, porque un agente no duda del documento: si AGENTS.md nombra un comando que ya no existe o un ancla que se movió, actúa sobre información falsa sin que nada avise. Se verifica que los `npm run` existan, que los enlaces y anclas resuelvan, y que los prefijos de alerta sigan estando en el enum de adev — si upstream añade o quita uno, salta aquí. --- AGENTS.md | 76 ++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 5 +++ GEMINI.md | 5 +++ tools/agents-md.test.mjs | 69 ++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 GEMINI.md create mode 100644 tools/agents-md.test.mjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..bac5ef5d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +--- +trigger: always_on +--- + +Este repositorio es la traducción al español de la documentación de Angular +(angular.dev → angular.lat). Esta guía es para agentes de IA que trabajen aquí. + +**Escribe siempre en español**: commits, PRs, issues, comentarios de código y +respuestas. El proyecto entero está en español. + +## Cómo está montado + +No es una copia del sitio, es una **capa de traducción**: + +- `origin/` — submódulo con el `angular/angular` en inglés, fijado a un SHA. +- `adev-es/` — solo lo traducido. El build copia `origin` entero y superpone + `adev-es` encima, así que lo que no esté traducido sale en inglés y no rompe. +- `xxx.md` es la traducción; `xxx.en.md` es el inglés del que se partió. + +Ese respaldo `.en.*` **no es opcional**: es lo único que le dice a +`update-origin` que el archivo ya está traducido. Sin él, la siguiente +sincronización le escribe el inglés encima sin aviso. Vale para cualquier +extensión, no solo `.md` — la navegación y el pie de página son `.ts` y `.html`. + +## Comandos + +```shell +npm run check-translations # qué falta traducir y qué se desactualizó +npm run lint-glossary # terminología contra glosario.yml +npm run plan-translation # qué bloques tocar en una traducción desactualizada +npm run verify-translation # comprueba que solo se tocó lo previsto +npm test # tests de las herramientas +npm run build # compila el sitio +``` + +## Documentación + +- [CONTRIBUTING.md](CONTRIBUTING.md) — flujo completo. Anclas útiles: + [`#respaldo`](CONTRIBUTING.md#respaldo), + [`#actualizar`](CONTRIBUTING.md#actualizar), + [`#antes-del-pr`](CONTRIBUTING.md#antes-del-pr). +- [UPDATE-ORIGIN.md](UPDATE-ORIGIN.md) — sincronizar con una versión nueva. +- [glosario.yml](glosario.yml) — reglas de terminología, cada una con su motivo. + +## Reglas que rompen cosas si se ignoran + +- **`.md` y `.en.md` van en el mismo commit.** Separarlos deja el archivo + marcado como desactualizado de forma permanente: la detección busca el commit + que tocó ambos. +- **No repitas `cp archivo.md archivo.en.md`** si el `.en.md` ya existe. + Escribirías español sobre el respaldo y se perdería el registro del original. +- **Los prefijos de alerta se quedan en inglés**: `NOTE:`, `TIP:`, `IMPORTANT:`, + `HELPFUL:`, `CRITICAL:`, `SUMMARY:`, `QUESTION:`. Son claves del tokenizer de + adev, no prosa; traducirlas hace que el aviso se renderice como párrafo plano. + Traduce solo el texto que sigue. +- **No traduzcas código, rutas, URLs ni nombres de API.** Sí los comentarios + dentro del código y los atributos con prosa visible (`title=`, `header=`). +- **Mantén el número de líneas** entre el original y la traducción cuando se + pueda: es lo que hace legibles los diffs futuros. + +## Al actualizar una traducción desactualizada + +No se retraduce: se aplica solo el cambio que ocurrió en inglés. Lee el +documento completo en ambos idiomas —la traducción existente es la mejor +referencia de terminología y registro— pero edita únicamente los bloques que +indique `plan-translation`. + +## Issues y PRs + +- Usa la CLI `gh`. +- Los issues de traducción se agrupan **por sección**, no uno por archivo, y + siguen la convención del repo: `Traducir - Guías de X`, `Actualizar - X`, + con prefijo de versión cuando aplica: `[Angular 22.1] Traducir …`. +- Etiqueta siempre con `docs-translation`. +- Antes de crear issues, comprueba con `gh issue list` qué existe ya: el repo + los mantiene a mano y duplicarlos es peor que no crearlos. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8658f796 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Las instrucciones de este repositorio están en [AGENTS.md](AGENTS.md), que es la +convención común a todas las herramientas de agente. + +Este archivo existe solo para que las que buscan un nombre propio lo encuentren. +No dupliques contenido aquí: cualquier cambio va en `AGENTS.md`. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000..8658f796 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,5 @@ +Las instrucciones de este repositorio están en [AGENTS.md](AGENTS.md), que es la +convención común a todas las herramientas de agente. + +Este archivo existe solo para que las que buscan un nombre propio lo encuentren. +No dupliques contenido aquí: cualquier cambio va en `AGENTS.md`. diff --git a/tools/agents-md.test.mjs b/tools/agents-md.test.mjs new file mode 100644 index 00000000..b2b382f7 --- /dev/null +++ b/tools/agents-md.test.mjs @@ -0,0 +1,69 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +/** + * AGENTS.md le dice a un agente qué comandos existen y dónde está la + * documentación. Si esas referencias se quedan atrás, el agente actúa sobre + * información falsa sin que nada avise — y a diferencia de una persona, no va a + * dudar del documento. + */ + +const ROOT = resolve(import.meta.dirname, '..'); +const doc = readFileSync(resolve(ROOT, 'AGENTS.md'), 'utf8'); +const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')); + +test('existe y declara el trigger que esperan las herramientas', () => { + assert.match(doc, /^---\ntrigger: always_on\n---/, 'falta el frontmatter'); +}); + +test('todos los `npm run` que menciona existen', () => { + const usados = [...doc.matchAll(/npm run ([a-z-]+)/g)].map((m) => m[1]); + assert.ok(usados.length >= 5, 'apenas menciona comandos'); + for (const c of new Set(usados)) { + assert.ok(pkg.scripts[c], `AGENTS.md menciona "npm run ${c}" y no existe`); + } +}); + +test('los archivos que enlaza existen', () => { + const enlaces = [...doc.matchAll(/\]\(([^)#]+\.(?:md|yml))(?:#[\w-]+)?\)/g)].map((m) => m[1]); + assert.ok(enlaces.length, 'no enlaza nada'); + for (const f of new Set(enlaces)) { + assert.ok(existsSync(resolve(ROOT, f)), `enlaza ${f}, que no existe`); + } +}); + +test('las anclas de CONTRIBUTING que cita existen', () => { + const contributing = readFileSync(resolve(ROOT, 'CONTRIBUTING.md'), 'utf8'); + const anclas = [...doc.matchAll(/CONTRIBUTING\.md#([\w-]+)/g)].map((m) => m[1]); + assert.ok(anclas.length, 'no cita ninguna ancla'); + for (const a of new Set(anclas)) { + assert.ok(contributing.includes(`{#${a}}`), `cita #${a} y CONTRIBUTING no la define`); + } +}); + +// Traducir un prefijo que no está en el enum rompe el renderizado del aviso. +// Si upstream añade o quita uno, este test lo detecta antes que un lector. +test('los prefijos de alerta que nombra existen en el tokenizer de adev', () => { + const src = resolve(ROOT, 'origin/adev/shared-docs/pipeline/shared/marked/extensions/docs-alert.mts'); + if (!existsSync(src)) return; // submódulo sin inicializar + + const claves = [...readFileSync(src, 'utf8').matchAll(/^\s+([A-Z]+)\s*=/gm)].map((m) => m[1]); + const nombrados = [...doc.matchAll(/`([A-Z]+):`/g)].map((m) => m[1]); + + assert.ok(nombrados.length >= 5, 'apenas nombra prefijos'); + for (const p of new Set(nombrados)) { + assert.ok(claves.includes(p), `nombra "${p}:" y no está en AlertSeverityLevel`); + } +}); + +test('los punteros por herramienta apuntan a AGENTS.md y no duplican', () => { + for (const f of ['CLAUDE.md', 'GEMINI.md']) { + const p = resolve(ROOT, f); + assert.ok(existsSync(p), `falta ${f}`); + const c = readFileSync(p, 'utf8'); + assert.match(c, /AGENTS\.md/, `${f} no apunta a AGENTS.md`); + assert.ok(c.length < 600, `${f} parece duplicar contenido en vez de apuntar`); + } +}); From 3ae7dc4ce586c2219ce27107e15d1aae5b2a2258 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Wed, 19 Aug 2026 19:43:23 -0400 Subject: [PATCH 28/28] =?UTF-8?q?refactor:=20los=20skills=20viven=20en=20.?= =?UTF-8?q?agents,=20con=20enlace=20simb=C3=B3lico=20para=20Claude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .agents/skills//SKILL.md es la ubicación neutra —la que lee Antigravity— y encaja con AGENTS.md, que ya es la convención común para las instrucciones. Claude Code espera .claude/skills, pero usa exactamente el mismo formato, así que basta un enlace simbólico. Git lo guarda como un solo objeto (modo 120000): cero duplicación y nada que sincronizar. Añadir un skill lo hace aparecer en ambas rutas a la vez. Se descarta el soporte para Gemini. Su formato es distinto —TOML con description y prompt, no SKILL.md— así que habría hecho falta generarlos y mantener un comando de sincronización. No se necesita, y el coste no se justificaba. Nota sobre portabilidad: en Windows los enlaces simbólicos exigen core.symlinks activado. Si a alguien no le resuelve .claude/skills, los skills siguen accesibles en .agents/skills. --- {.claude => .agents}/skills/batch-translate/SKILL.md | 0 .../skills/crear-issues-traduccion/SKILL.md | 0 .../skills/translate-angular-docs/SKILL.md | 0 {.claude => .agents}/skills/translate-delta/SKILL.md | 0 .claude/skills | 1 + .gitignore | 2 ++ AGENTS.md | 9 +++++++++ GEMINI.md | 5 ----- tools/agents-md.test.mjs | 2 +- 9 files changed, 13 insertions(+), 6 deletions(-) rename {.claude => .agents}/skills/batch-translate/SKILL.md (100%) rename {.claude => .agents}/skills/crear-issues-traduccion/SKILL.md (100%) rename {.claude => .agents}/skills/translate-angular-docs/SKILL.md (100%) rename {.claude => .agents}/skills/translate-delta/SKILL.md (100%) create mode 120000 .claude/skills delete mode 100644 GEMINI.md diff --git a/.claude/skills/batch-translate/SKILL.md b/.agents/skills/batch-translate/SKILL.md similarity index 100% rename from .claude/skills/batch-translate/SKILL.md rename to .agents/skills/batch-translate/SKILL.md diff --git a/.claude/skills/crear-issues-traduccion/SKILL.md b/.agents/skills/crear-issues-traduccion/SKILL.md similarity index 100% rename from .claude/skills/crear-issues-traduccion/SKILL.md rename to .agents/skills/crear-issues-traduccion/SKILL.md diff --git a/.claude/skills/translate-angular-docs/SKILL.md b/.agents/skills/translate-angular-docs/SKILL.md similarity index 100% rename from .claude/skills/translate-angular-docs/SKILL.md rename to .agents/skills/translate-angular-docs/SKILL.md diff --git a/.claude/skills/translate-delta/SKILL.md b/.agents/skills/translate-delta/SKILL.md similarity index 100% rename from .claude/skills/translate-delta/SKILL.md rename to .agents/skills/translate-delta/SKILL.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 00000000..2b7a412b --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.gitignore b/.gitignore index eae24149..8519c579 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ Thumbs.db # Órdenes de traducción generadas (efímeras, no se versionan) .translation-plan/ + +.remember diff --git a/AGENTS.md b/AGENTS.md index bac5ef5d..9ecd48c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,3 +74,12 @@ indique `plan-translation`. - Etiqueta siempre con `docs-translation`. - Antes de crear issues, comprueba con `gh issue list` qué existe ya: el repo los mantiene a mano y duplicarlos es peor que no crearlos. + +## Skills + +Viven en `.agents/skills//SKILL.md`, que es el nombre neutro y el que +lee Antigravity directamente. + +`.claude/skills` es un **enlace simbólico** a esa carpeta: Claude Code espera su +propia ruta pero usa el mismo formato, así que no hay copias que sincronizar. Si +añades un skill, aparece en ambos sitios solo. diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 8658f796..00000000 --- a/GEMINI.md +++ /dev/null @@ -1,5 +0,0 @@ -Las instrucciones de este repositorio están en [AGENTS.md](AGENTS.md), que es la -convención común a todas las herramientas de agente. - -Este archivo existe solo para que las que buscan un nombre propio lo encuentren. -No dupliques contenido aquí: cualquier cambio va en `AGENTS.md`. diff --git a/tools/agents-md.test.mjs b/tools/agents-md.test.mjs index b2b382f7..d714aa6f 100644 --- a/tools/agents-md.test.mjs +++ b/tools/agents-md.test.mjs @@ -59,7 +59,7 @@ test('los prefijos de alerta que nombra existen en el tokenizer de adev', () => }); test('los punteros por herramienta apuntan a AGENTS.md y no duplican', () => { - for (const f of ['CLAUDE.md', 'GEMINI.md']) { + for (const f of ['CLAUDE.md']) { const p = resolve(ROOT, f); assert.ok(existsSync(p), `falta ${f}`); const c = readFileSync(p, 'utf8');