diff --git a/.agents/skills/batch-translate/SKILL.md b/.agents/skills/batch-translate/SKILL.md new file mode 100644 index 00000000..49f4d000 --- /dev/null +++ b/.agents/skills/batch-translate/SKILL.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/.agents/skills/crear-issues-traduccion/SKILL.md b/.agents/skills/crear-issues-traduccion/SKILL.md new file mode 100644 index 00000000..e226458d --- /dev/null +++ b/.agents/skills/crear-issues-traduccion/SKILL.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.** diff --git a/.agents/skills/translate-angular-docs/SKILL.md b/.agents/skills/translate-angular-docs/SKILL.md new file mode 100644 index 00000000..a566221b --- /dev/null +++ b/.agents/skills/translate-angular-docs/SKILL.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/.agents/skills/translate-delta/SKILL.md b/.agents/skills/translate-delta/SKILL.md new file mode 100644 index 00000000..924f6da0 --- /dev/null +++ b/.agents/skills/translate-delta/SKILL.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/SKILL.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/SKILL.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/.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/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..ac62ba43 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,21 @@ +# 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: + - 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/traducir.yml b/.github/ISSUE_TEMPLATE/traducir.yml new file mode 100644 index 00000000..9f0dae8a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/traducir.yml @@ -0,0 +1,98 @@ +name: Traducir documentación +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: + + ```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: 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: + 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, de qué versión vienen las páginas. + placeholder: Páginas nuevas añadidas en Angular 22.1. + + - type: markdown + attributes: + value: | + --- + + ### Si vas a traducir una página nueva + + ```shell + # 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. + + ```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 + ``` + + 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/.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 + + diff --git a/.gitignore b/.gitignore index 1fe1e6d0..8519c579 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,11 @@ Thumbs.db # Firebase Caching .firebase + +# 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/ + +.remember diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9ecd48c4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +--- +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. + +## 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/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/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) | diff --git a/UPDATE-ORIGIN.md b/UPDATE-ORIGIN.md index 667b5e53..f4cee803 100644 --- a/UPDATE-ORIGIN.md +++ b/UPDATE-ORIGIN.md @@ -36,6 +36,66 @@ 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 cinco estados. Los tres últimos son silenciosos: sin ellos, esos archivos se cuentan como correctos. + +| 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: + +```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 +``` + +### Crear los issues + +Los issues de traducción se crean **a mano**, agrupados por sección, siguiendo la convención del repo: + +``` +[Angular 22.1] Traducir guías de Signal Forms +Traducir - Press Kit +``` + +`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 +``` + +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` + +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/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; 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 7950967d..00000000 Binary files a/adev-es/src/assets/textures/construir-para-todos.png and /dev/null differ 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..205d3b99 --- /dev/null +++ b/glosario.yml @@ -0,0 +1,90 @@ +# 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"' + + # Los límites se escriben con \p{L} y no con \b: el \b de JavaScript se define + # sobre [A-Za-z0-9_], así que la ñ y las vocales acentuadas cuentan como + # separadores. Sin esto, la regla marcaba "señalar", "señalización", "señalan" + # y "Señala" — y con --fix los reescribiría como "signalar", "signalización". + - expected: signal + pattern: '(? { + 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']) { + 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`); + } +}); 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/check-translations.mjs b/tools/check-translations.mjs new file mode 100644 index 00000000..48a946eb --- /dev/null +++ b/tools/check-translations.mjs @@ -0,0 +1,539 @@ +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, '..'); + +/** + * Verifica el estado de sincronización de las traducciones. + * + * 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. 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`. 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. + * + * Uso: + * 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) + * npm run check-translations -- --issues (borradores de issue por lote) + */ + +$.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}/`, ''); + 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*$/]; +const URL_NOISE = /https:\/\/angular\.dev\//; + +try { + const ref = argv.ref ?? 'HEAD'; + + const all = await listFiles(ref); + 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. 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 = []; + const unprotected = []; + const orphans = []; + const unpaired = []; + 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 (!isEnFile(f)) continue; + 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); + } + } + } + + const live = ref === 'HEAD'; + + for (const md of sources) { + const en = enPathOf(md); + + // 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 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; + } + + 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 ${base.sha}:${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 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; + } + + if (before === now) { + synced++; + continue; + } + + 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, + category: categorize(md), + since: base.sha.slice(0, 7), + baselineKind: base.kind, + ...classify(diff), + }); + } + + const payload = { stale, untranslated, unprotected, orphans, unpaired, skipped, synced, + analyzed: sources, originAvailable: originFiles !== null }; + + if (argv.json) { + reportJson(payload); + } else if (argv.issues) { + reportIssues(payload); + } else { + report({ ...payload, ref }); + } + + 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 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 + * 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} -- ${ES_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 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(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 + // archivo de adev-es que viva fuera de ellos. + const files = await glob('**/*', { cwd: dir, onlyFiles: true }); + return files.length ? new Set(files) : 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`. + * + * 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 + * 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 }; +} + +/** + * 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, unprotected, orphans, unpaired, skipped, synced, analyzed, originAvailable }) { + console.log( + JSON.stringify( + { + synced, + analyzed: analyzed.length, + skipped, + originAvailable, + 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 }) => ({ + path: source.replace(`${CONTENT_DIR}/`, ''), + file: source, + category, + since, + prose, + noise, + diff, + })), + untranslated: untranslated.map((f) => ({ + path: short(f), + file: f, + category: categorize(f), + })), + }, + null, + 2 + ) + ); +} + +/** + * 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); + + 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}`); + console.log(` ${chalk.red('✘')} Sin traducir: ${untranslated.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 los destruye:\n')); + for (const f of unprotected) { + console.log(` ${short(f)}`); + 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`)); + } + } + + 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 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, + // 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')); + 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}`)); + // 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`)); + } + } + + 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/glossary.test.mjs b/tools/glossary.test.mjs new file mode 100644 index 00000000..a3ec2bb6 --- /dev/null +++ b/tools/glossary.test.mjs @@ -0,0 +1,119 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { YAML } from 'zx'; +import { mask, lintText } from './lib/glossary.mjs'; + +const ROOT = resolve(import.meta.dirname, '..'); +const { rules } = YAML.parse(readFileSync(resolve(ROOT, 'glosario.yml'), 'utf8')); + +const hits = (text) => 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}"`); + } +}); + +// 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 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', () => { + 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/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/issue-templates.test.mjs b/tools/issue-templates.test.mjs new file mode 100644 index 00000000..3d7a554a --- /dev/null +++ b/tools/issue-templates.test.mjs @@ -0,0 +1,101 @@ +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'))]); + +// 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 >= 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', () => { + 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`); + } + } +}); diff --git a/tools/lib/glossary.mjs b/tools/lib/glossary.mjs new file mode 100644 index 00000000..88561f62 --- /dev/null +++ b/tools/lib/glossary.mjs @@ -0,0 +1,61 @@ +/** + * 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 + // 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 +} + +/** + * 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/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; +} diff --git a/tools/lib/targets.mjs b/tools/lib/targets.mjs new file mode 100644 index 00000000..a153eddf --- /dev/null +++ b/tools/lib/targets.mjs @@ -0,0 +1,86 @@ +/** + * 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', + // 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 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('.'); + 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/lint-glossary.mjs b/tools/lint-glossary.mjs new file mode 100644 index 00000000..5601e221 --- /dev/null +++ b/tools/lint-glossary.mjs @@ -0,0 +1,78 @@ +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. + * + * 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(...lintText(file, text, rules)); + } + + 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)); + process.exit(1); +} + +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/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/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 f722d6d8..8599ab88 100644 --- a/tools/update-origin.mjs +++ b/tools/update-origin.mjs @@ -1,21 +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'; -const copyTargets = [ - // Text contents - 'src/content/**/*.md', - '!src/content/**/license.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...')); @@ -45,21 +32,29 @@ async function copyOriginFiles() { const adevOriginDir = 'origin/adev'; const adevEsDir = 'adev-es'; - const files = await glob(copyTargets, { cwd: adevOriginDir }); - - for (const file of files) { - const src = resolve(adevOriginDir, file); - const ext = extname(file); - const enFilePath = file.replace(`${ext}`, `.en${ext}`); - - 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); + for (const target of copyTargets) { + const files = await glob(target, { cwd: adevOriginDir, caseSensitiveMatch: true }); + + // 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)}`); + } + + for (const file of files) { + const src = resolve(adevOriginDir, file); + const enFilePath = enPathOf(file); + + 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 +} 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') + ); +}