Módulo 25: Internacionalización y PWA
Objetivos del módulo
- Distinguir internacionalización, localización y locale, y saber qué cambia de un idioma a otro además del texto
- Preparar un proyecto con
@angular/localizey entender qué modificang add - Marcar textos en plantillas con
i18ny en TypeScript con$localize, con descripción, significado e identificador propio - Escribir plurales y selecciones con la sintaxis ICU, y entender las categorías plurales de cada idioma
- Extraer los textos con
ng extract-i18n, traducirlos y mantener los ficheros cuando la aplicación cambia - Compilar una aplicación por idioma, y saber qué genera
ng buildy qué ocurre por dentro - Usar
LOCALE_IDy cambiar de idioma mediante enlaces, y servir cada idioma con Node o Nginx - Comparar la traducción en compilación con la traducción en ejecución, y usar
loadTranslations - Explicar qué es una PWA y qué hace un service worker
- Convertir la aplicación EMPRESA en una PWA con
ng add @angular/pway ajustarngsw-config.json - Guardar respuestas de la API con
dataGroupsy elegir entreperformanceyfreshness - Probar de verdad el modo sin conexión y depurar el service worker
- Avisar de versiones nuevas con
SwUpdatey tratar los estados irrecuperables - Configurar el manifest web y conocer las notificaciones push con
SwPush - Combinar los dos temas: un service worker independiente por idioma
💡 Consejo: Si usas la LTS (Angular 21), este módulo te sirve casi entero tal cual. Hemos comparado los paquetes
@angular/localizey@angular/service-workerde 21.2.25 y 22.2.4: tienen las mismas APIs públicas y el mismo comportamiento, y la aplicación del módulo compila en ambas versiones. Lo único marcado son los servicios escritos con@Service(). Los ejemplos usan ademáshttpResource, que en la LTS es experimental, como explica el módulo 13.
1. Qué significa internacionalizar
Hasta ahora, la aplicación EMPRESA habla español porque los textos están escritos directamente en las plantillas. Supón que la empresa abre una oficina en Londres y otra en Lyon. Traducir no consiste solo en cambiar unas frases, porque también cambian muchas cosas que no parecen texto:
| Dato | Español (España) | Inglés (Estados Unidos) | Francés (Francia) |
|---|---|---|---|
| Importe | 2.600,00 € | €2,600.00 | 2 600,00 € |
| Fecha | 16 mar 2020 | Mar 16, 2020 | 16 mars 2020 |
| Plural con cero | 0 empleados | 0 employees | 0 employé |
| Orden de las palabras | Datos a 2 de noviembre | Data as of November 2 | Données au 2 novembre |
Los importes y las fechas son salidas reales de la aplicación de este módulo. Fíjate en el plural francés, que usa el singular para el cero.
Hay tres términos que conviene separar:
- Internacionalización (i18n, porque entre la i y la n de internationalization hay 18 letras): preparar el código para que pueda funcionar en varios idiomas. La hace quien programa.
- Localización (l10n): adaptar la aplicación ya preparada a un idioma y región concretos. La hace quien traduce, con ayuda del compilador.
- Locale: el identificador de un idioma y, opcionalmente, una región:
es,es-MX,en-GB,fr. Determina los formatos de fechas y números, y las reglas de plural.
📘 Concepto: El módulo 07 ya mostró
LOCALE_IDyregisterLocaleDatapara formatear fechas y números en español. Eso resuelve el formato, pero no traduce los textos.@angular/localizeañade la parte que faltaba y, como verás, también se encarga de configurar el formato de cada idioma.
Angular trabaja por defecto en compilación: marcas los textos, los extraes a un fichero, los traduces, y ng build genera una copia completa de la aplicación por cada idioma.
Figura 1. El flujo completo. Lo repetirás cada vez que añadas o cambies textos.
Este módulo se apoya en una aplicación pequeña con dos pantallas, empleados y departamentos, que usa la API EMPRESA. Las capturas son reales y está probada en tres idiomas: español (idioma original), inglés y francés.
2. Preparar el proyecto
El soporte de traducción está en un paquete aparte:
ng add @angular/localize
Esta orden instala @angular/localize y toca cuatro cosas:
| Fichero | Cambio | Para qué sirve |
|---|---|---|
package.json | Añade la dependencia | Incluye la función $localize y las herramientas de traducción |
angular.json | "polyfills": ["@angular/localize/init"] | Hace que $localize exista como global en el navegador |
tsconfig.app.json y tsconfig.spec.json | "types": ["@angular/localize"] | TypeScript conoce $localize |
src/main.ts | /// <reference types="@angular/localize" /> | Lo mismo, desde el punto de entrada |
Después declara en angular.json en qué idioma está escrito el código y a qué idiomas lo traducirás. Va dentro del proyecto, al mismo nivel que architect:
{
"projects": {
"empresa": {
"i18n": {
"sourceLocale": "es",
"locales": {
"en": { "translation": "src/locale/messages.en.xlf" },
"fr": { "translation": "src/locale/messages.fr.xlf" }
}
},
"architect": {
}
}
}
}
⚠️ Importante:
sourceLocalevaleen-USsi no lo indicas. Si escribes los textos en español y olvidas esta línea, los ficheros de traducción dirán que el idioma original es el inglés, y las fechas de la versión original saldrán en formato estadounidense.
3. Marcar textos en las plantillas
Marcar un texto consiste en añadir el atributo i18n al elemento que lo contiene:
<h1 i18n>Directorio de empleados</h1>
El atributo no llega al HTML final. Es una instrucción para el compilador, que dice: «el contenido de este elemento hay que traducirlo».
Descripción, significado e identificador
Quien traduce suele ver una lista de frases sueltas, sin la pantalla delante. Si encuentra la palabra «Alta», no sabe si es una fecha de incorporación o una estatura. El atributo admite tres datos, con este formato:
i18n="significado|descripción@@identificador"
<h1 i18n="Pantalla principal|Título del directorio de empleados@@directorio.titulo">
Directorio de empleados
</h1>
<th i18n="Fecha de incorporación@@directorio.col.alta">Alta</th>
| Parte | Obligatoria | Efecto |
|---|---|---|
| Significado | No | Distingue dos usos del mismo texto. Sin identificador propio, forma parte del identificador generado |
| Descripción | No | Solo informa a quien traduce. Con un único dato antes de @@, Angular lo toma como descripción |
@@identificador | No | Nombre propio y estable del mensaje |
Sin identificador propio, Angular calcula uno a partir del texto y del significado, por ejemplo 8510684221969247992. Eso tiene una consecuencia importante: si corriges una coma del texto original, cambia el identificador y la traducción deja de encontrarse. Con @@directorio.titulo, el texto puede cambiar sin perder la relación.
💡 Consejo: Usa identificadores propios con una jerarquía sencilla (
pantalla.elemento). En un proyecto pequeño puede parecer un trabajo extra, pero evita traducir de nuevo por cambios mínimos y hace legibles los ficheros y los diffs.
Con el mismo identificador y distinto texto, el compilador avisa de un duplicado. Se controla con la opción i18nDuplicateTranslation, que admite warning, error o ignore.
Interpolaciones y elementos dentro del texto
Las frases se marcan enteras, con todo lo que llevan dentro:
<p i18n="@@directorio.intro">
Consulta la plantilla de <strong>{{ empresa }}</strong>. Datos a {{ hoy | date: 'longDate' }}.
</p>
En el fichero de traducción aparecerán marcadores en el lugar de la negrita y de las interpolaciones. Quien traduce puede moverlos, porque el orden de las palabras cambia de un idioma a otro. Por eso nunca debes trocear una frase en varios elementos marcados por separado:
<!-- MAL: la traducción no puede reordenar la frase -->
<span i18n>Hay</span> {{ total }} <span i18n>empleados</span>
<!-- BIEN: una sola unidad de traducción -->
<span i18n>Hay {{ total }} empleados</span>
Atributos
El texto de un atributo se marca con i18n-<atributo>:
<input
type="search"
placeholder="Nombre o apellidos"
i18n-placeholder="Ayuda dentro del buscador@@directorio.buscar.ayuda"
(input)="buscar($event)"
/>
<nav aria-label="Menú principal" i18n-aria-label="@@menu.etiqueta">...</nav>
Recuerda title, alt, placeholder y los atributos aria-*. Son los que más se olvidan, y los lectores de pantalla los leen en voz alta, como viste en el módulo 21.
Texto sin elemento propio
Si el texto no tiene un elemento y no quieres añadir uno, usa ng-container, que no genera HTML:
<ng-container i18n="@@aviso.resumen">Revisa los datos antes de guardar</ng-container>
4. Textos en TypeScript con $localize
No todos los textos están en plantillas. Un mensaje de error, el título de una ruta o una etiqueta calculada viven en el código. Para ellos existe $localize, una función que se usa como plantilla etiquetada de JavaScript (tagged template):
// Sin metadatos
const aviso = $localize`Datos guardados`;
// Con descripción e identificador, entre dos puntos al principio
protected readonly mensajeError =
$localize`:Aviso cuando falla la API@@directorio.error:No se pudo cargar la lista de empleados`;
Los metadatos usan el mismo formato que i18n: :significado|descripción@@id:texto. Y las variables pueden llevar un nombre para el marcador, escrito justo después de la expresión:
const saludo = $localize`:@@saludo:Hola, ${nombre}:persona:. Tienes acceso.`;
Así, quien traduce ve {$persona} en lugar de un genérico {$PH}.
Los títulos de ruta, que viste en el módulo 10, son un caso muy común:
// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { Directorio } from './directorio/directorio';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'empleados' },
{
path: 'empleados',
title: $localize`:Título de la pestaña@@ruta.empleados:Empleados | EMPRESA`,
component: Directorio,
},
{
path: 'departamentos',
title: $localize`:Título de la pestaña@@ruta.departamentos:Departamentos | EMPRESA`,
loadComponent: () =>
import('./departamentos/departamentos').then((m) => m.Departamentos),
},
];
⚠️ Importante: La plantilla etiquetada debe ser literal.
$localizeno puede traducir un texto que construyes tú en una variable, ni uno que llega de la API. Los nombres de empleados, los oficios o las descripciones de productos son datos, no textos de la interfaz. En las capturas verás quePROGRAMADORno se traduce. Traducir datos es responsabilidad del servidor.
5. Plurales y selecciones
Escribir {{ total }} empleados es correcto para 2, pero no para 1. Un @if por cada caso no sirve, porque cada idioma tiene reglas distintas. La solución es la sintaxis ICU (International Components for Unicode), un estándar para expresar variantes de un mensaje.
Plurales
<p class="total" i18n="@@directorio.total">
{total, plural,
=0 {No hay empleados}
=1 {Hay un empleado}
other {Hay {{ total }} empleados}
}
</p>
La estructura es {expresión, plural, caso {texto} caso {texto}}. Los casos con = comparan con un número exacto. Los demás son categorías que dependen del idioma, y other es obligatoria:
| Categoría | Uso típico |
|---|---|
zero, one, two, few, many | Grupos de números según las reglas de cada idioma |
other | Todo lo demás |
Ninguna categoría significa lo que parece. one no es «el número uno», sino «los números que se tratan como singular en este idioma». Este programa lo muestra con Intl.PluralRules, la API que viste en el módulo 07:
for (const idioma of ['es', 'en', 'fr', 'ru', 'ar', 'ja']) {
const categorias = [0, 1, 2, 5, 21, 1000000].map((n) => new Intl.PluralRules(idioma).select(n));
console.log(idioma, categorias.join(' '));
}
es other one other other other many
en other one other other other other
fr one one other other other many
ru many one few many one many
ar zero one two few many other
ja other other other other other other
La salida es muy reveladora. En francés, el 0 es one. El ruso trata el 21 igual que el 1, porque acaba en uno. El árabe usa las seis categorías. El japonés no distingue nada. Y en español, un millón es many: se dice «un millón de empleados», no «un millón empleados».
Por eso, cada traducción puede usar casos diferentes de los del original. Este es el plural francés de la aplicación:
{VAR_PLURAL, plural, =0 {Aucun employé} one {<x id="INTERPOLATION"/> employé} other {<x id="INTERPOLATION"/> employés}}
Selecciones
select elige un texto según el valor de una cadena. Sirve para estados, géneros o cualquier conjunto cerrado de opciones:
<span class="insignia" [class.apagada]="modo() === 'offline'" i18n="@@conexion.estado">
{modo(), select, online {Conectado} offline {Sin conexión} other {Desconocido}}
</span>
Las claves online y offline no se traducen, porque son los valores que devuelve modo(). Solo se traducen los textos entre llaves. other también es obligatoria aquí.
Un plural y un select se pueden anidar, como harás en el ejercicio 2. Sirve para concordar, por ejemplo, género y número.
💡 Consejo: Un
selecttambién sirve para sustituir una construcción habitual y dañina:{{ activo ? 'Sí' : 'No' }}. Esos literales dentro de una expresión no se pueden marcar coni18n, así que quedan sin traducir sin ningún aviso.
6. Extraer los textos
Con todo marcado, esta orden recorre la aplicación y escribe un fichero con cada mensaje:
ng extract-i18n --output-path src/locale
Application bundle generation complete.
Extraction Complete. (Messages: 23)
📘 Concepto:
extract-i18ncompila la aplicación antes de extraer. Por eso tarda lo mismo que unng buildy falla si hay errores de compilación. Solo extrae los textos de lo que forma parte de la aplicación: un componente que nadie importa no aparece, aunque llevei18n.
El resultado es src/locale/messages.xlf, en formato XLIFF 1.2, un estándar XML que entienden las herramientas de traducción. Esta es una de sus unidades:
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="es" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="menu.etiqueta" datatype="html">
<source>Menú principal</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.html</context>
<context context-type="linenumber">4</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>
| Opción | Efecto |
|---|---|
--output-path | Carpeta de salida |
--out-file | Nombre del fichero, en lugar de messages.xlf |
--format | xlf (por defecto), xlf2, xmb, json, arb |
Un plural genera dos unidades
Una sorpresa frecuente: hemos marcado un solo <span> con el select y aparecen dos unidades:
<trans-unit id="conexion.estado" datatype="html">
<source> <x id="ICU" equiv-text="{modo(), select, online {Conectado} offline {Sin conexión} other {Desconocido}}" xid="2298327741772557399"/> </source>
</trans-unit>
<trans-unit id="2298327741772557399" datatype="html">
<source>{VAR_SELECT, select, online {Conectado} offline {Sin conexión} other {Desconocido}}</source>
</trans-unit>
La primera es el contenido del elemento, con un marcador ICU. La segunda es la expresión ICU, con su identificador calculado. Hay que traducir la segunda y copiar la primera tal cual. Si cambias el texto de cualquier caso, el identificador de la unidad ICU cambia, aunque el i18n tenga @@.
7. Traducir
Copia el fichero una vez por idioma y añade un elemento <target> a cada unidad, justo después de <source>:
cp src/locale/messages.xlf src/locale/messages.en.xlf
cp src/locale/messages.xlf src/locale/messages.fr.xlf
<trans-unit id="directorio.titulo" datatype="html">
<source> Directorio de empleados </source>
<target>Employee directory</target>
</trans-unit>
<trans-unit id="8510684221969247992" datatype="html">
<source>{VAR_PLURAL, plural, =0 {No hay empleados} =1 {Hay un empleado} other {Hay <x id="INTERPOLATION"/> empleados}}</source>
<target>{VAR_PLURAL, plural, =0 {No employees} =1 {One employee} other {<x id="INTERPOLATION"/> employees}}</target>
</trans-unit>
Las reglas son sencillas: no cambies los id, conserva los marcadores <x .../>, aunque puedes moverlos, y traduce solo el texto. En una frase con negrita, el destino debe tener también START_TAG_STRONG y CLOSE_TAG_STRONG:
<target>Browse the staff of <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="<strong>"/><x id="INTERPOLATION" equiv-text="{{ empresa }}"/><x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="</strong>"/>. Data as of <x id="INTERPOLATION_1" equiv-text="..."/>.</target>
En equipos reales, estos ficheros no se editan a mano: se suben a una herramienta de traducción que muestra el contexto y conserva lo ya traducido.
Mantener las traducciones
Cuando añades textos, repites la extracción. extract-i18n no toca messages.en.xlf ni messages.fr.xlf: solo regenera messages.xlf. Eres tú quien debe llevar las unidades nuevas a cada fichero traducido. Puedes comparar los id con un pequeño programa, con una herramienta de traducción o con un paquete de la comunidad.
Ese paso olvidado es el error más común, y el compilador puede vigilarlo con i18nMissingTranslation:
| Valor | Si falta una unidad |
|---|---|
warning (por defecto) | Aviso, y se usa el texto original |
error | La compilación falla |
ignore | Se usa el texto original sin decir nada |
"options": {
"i18nMissingTranslation": "error"
}
Esta es la salida real al compilar en francés con textos nuevos cuyas unidades no están en messages.fr.xlf. Con warning son avisos y la compilación termina. Con error aparecen estas mismas líneas como errores y no se genera nada:
X [ERROR] No translation found for "pie.privacidad" ("Privacidad").
X [ERROR] No translation found for "pie.legal" (" © {$INTERPOLATION} EMPRESA S.A. Todos los derechos reservados. " - "Pie de página").
X [ERROR] No translation found for "pedidos.resumen" (" {$ICU} ").
⚠️ Importante: Una unidad que existe sin
<target>no cuenta como traducción que falta. Lo hemos comprobado coni18nMissingTranslationenerror: la compilación termina bien y la versión francesa muestra el texto en español. Copiar unmessages.xlfentero y dejar unidades a medias es una forma silenciosa de que se cuelen textos sin traducir.
8. Compilar por idioma
Con los ficheros traducidos, activa localize en la configuración de producción de angular.json:
"configurations": {
"production": {
"localize": true,
"outputHashing": "all"
},
"en": { "localize": ["en"] },
"fr": { "localize": ["fr"] }
}
true compila todos los idiomas, incluido el original, y una lista compila solo los indicados. El resultado de ng build es este:
dist/empresa/
3rdpartylicenses.txt
browser/
en/
es/
fr/
Cada carpeta es una aplicación completa, y el index.html de cada una se ha adaptado solo:
<!doctype html>
<html lang="en" dir="ltr" data-beasties-container>
<head>
<meta charset="utf-8">
<title>EMPRESA</title>
<base href="/en/">
Angular ha cambiado tres cosas: lang, dir, con la dirección del texto según los datos del idioma, y base href, que apunta a la carpeta. Esa última es imprescindible, porque el router y las rutas relativas de los ficheros dependen de ella. Sin embargo, <title> no se ha traducido: el título real lo pone el router cuando navega, y por eso lo traducimos con $localize en las rutas.
Estas son las capturas de la misma pantalla en los tres idiomas:

Figura 2. Versión original en /es/empleados. El oficio es un dato de la API y no se traduce en ningún idioma.

Figura 3. Versión inglesa en /en/empleados.

Figura 4. Versión francesa en /fr/empleados. La moneda es la misma, y el formato, otro.
Qué ocurre por dentro
Compilar ocho idiomas no significa compilar ocho veces. El compilador convierte cada i18n en una llamada a $localize. Al terminar, una herramienta recorre el JavaScript ya empaquetado y sustituye cada llamada por el texto del idioma correspondiente, una vez por idioma. Eso es muy rápido comparado con compilar de nuevo.
Se aprecia en el resultado. En nuestra compilación, las tres carpetas contienen ficheros con el mismo nombre, aunque el contenido sea distinto:
104474 es/main-NEODMM5K.js
104496 fr/main-NEODMM5K.js
2247 es/polyfills-B6AWBRSW.js
2823 fr/polyfills-B6AWBRSW.js
El hash del nombre se calculó antes de traducir. Es otra razón para que cada idioma viva en su carpeta y para no mezclarlas al desplegar. El fichero polyfills cambia porque lleva los datos de formato del idioma. Por eso, en este tipo de compilación no necesitas registerLocaleData: el compilador lo hace por ti.
Probar un idioma en desarrollo
ng serve no compila todos los idiomas: sirve uno solo. Las configuraciones en y fr anteriores existen para esto, y hay que enlazarlas en el objetivo serve de angular.json:
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"en": { "buildTarget": "empresa:build:development,en" },
"fr": { "buildTarget": "empresa:build:development,fr" }
}
}
ng serve --configuration=fr
Hemos comprobado qué sirve: <html lang="fr" dir="ltr">, pero con <base href="/">. La aplicación francesa queda en la raíz, sin el prefijo /fr/, y las unidades que faltan salen como avisos en el terminal. Por eso, los enlaces de cambio de idioma de la sección 10 no funcionan con ng serve: para probarlos necesitas la compilación completa.
Cambiar las rutas de salida
Por defecto, el nombre de la carpeta y el base href son el código del idioma. La propiedad subPath cambia los dos a la vez:
"i18n": {
"sourceLocale": { "code": "es", "subPath": "espanol" },
"locales": {
"en": { "translation": "src/locale/messages.en.xlf", "subPath": "english" }
}
}
Solo admite letras, números, guiones y guiones bajos. También existe baseHref, pero el constructor recomienda subPath, y no se pueden usar juntas.
9. El idioma en ejecución: LOCALE_ID
Cada compilación recibe su valor de LOCALE_ID automáticamente. No hay que declararlo en app.config.ts. Los pipes date, number, currency y percent lo usan, y tú puedes leerlo con inject:
protected readonly idiomaActual = inject(LOCALE_ID); // 'es', 'en' o 'fr'
Sirve también para lo que Angular no formatea. Este pipe, que harás en el ejercicio 4, crea un Intl.NumberFormat con el idioma de la aplicación:
@Pipe({ name: 'compacta' })
export class CompactaPipe implements PipeTransform {
private readonly formato = new Intl.NumberFormat(inject(LOCALE_ID), {
notation: 'compact',
maximumFractionDigits: 1,
});
transform(valor: number): string {
return this.formato.format(valor);
}
}
Recuerda que LOCALE_ID no decide la moneda ni la zona horaria, como viste en el módulo 07. Un cliente en Londres sigue viendo euros si el importe está en euros: en las figuras 3 y 4, la divisa no cambia y el formato sí. Convertir importes es una decisión del negocio, no del idioma.
10. Cambiar de idioma
Como cada idioma es una aplicación distinta, cambiar de idioma es navegar a otra URL y recargar la página. No se usa routerLink, porque el router solo conoce las rutas de su propia aplicación. Se usa un enlace normal:
// src/app/app.ts
import { Component, LOCALE_ID, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { filter, map } from 'rxjs';
import { Actualizaciones } from './pwa/actualizaciones';
import { Conexion } from './pwa/conexion';
@Component({
selector: 'app-root',
imports: [RouterLink, RouterLinkActive, RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
private readonly router = inject(Router);
protected readonly idiomaActual = inject(LOCALE_ID);
protected readonly conexion = inject(Conexion);
protected readonly actualizaciones = inject(Actualizaciones);
// El nombre de cada idioma va en su propio idioma: no se traduce
private readonly idiomas = [
{ codigo: 'es', nombre: 'Español' },
{ codigo: 'en', nombre: 'English' },
{ codigo: 'fr', nombre: 'Français' },
];
private readonly ruta = toSignal(
this.router.events.pipe(
filter((e) => e instanceof NavigationEnd),
map((e) => e.urlAfterRedirects),
),
{ initialValue: '/' },
);
// Cada enlace conserva la pantalla actual al cambiar de idioma
protected readonly enlaces = computed(() =>
this.idiomas.map((i) => ({ ...i, url: `/${i.codigo}${this.ruta()}` })),
);
protected readonly modo = computed(() => (this.conexion.enLinea() ? 'online' : 'offline'));
}
Conexion y Actualizaciones son dos servicios que escribiremos en la parte de PWA. La plantilla completa de esta aplicación es esta:
<!-- src/app/app.html -->
<header>
<strong class="marca">EMPRESA</strong>
<nav aria-label="Menú principal" i18n-aria-label="@@menu.etiqueta">
<a routerLink="/empleados" routerLinkActive="activo" i18n="@@menu.empleados">Empleados</a>
<a routerLink="/departamentos" routerLinkActive="activo" i18n="@@menu.departamentos">Departamentos</a>
</nav>
<span class="insignia" [class.apagada]="modo() === 'offline'" i18n="@@conexion.estado">
{modo(), select, online {Conectado} offline {Sin conexión} other {Desconocido}}
</span>
<ul class="idiomas">
@for (i of enlaces(); track i.codigo) {
<li>
<a [href]="i.url" [attr.lang]="i.codigo" [attr.hreflang]="i.codigo"
[class.activo]="i.codigo === idiomaActual">{{ i.nombre }}</a>
</li>
}
</ul>
</header>
@if (actualizaciones.versionRota()) {
<aside class="aviso">
<span i18n="@@pwa.rota">Esta versión ya no se puede usar. Recarga la página.</span>
</aside>
} @else if (actualizaciones.hayVersionNueva()) {
<aside class="aviso">
<span i18n="@@pwa.nueva">Hay una versión nueva de la aplicación.</span>
<button type="button" (click)="actualizaciones.aplicar()" i18n="@@pwa.actualizar">Actualizar</button>
</aside>
}
<main>
<router-outlet />
</main>
El atributo lang del enlace no es decorativo. Sin él, un lector de pantalla configurado en inglés pronunciaría «Français» con reglas inglesas. Y fíjate en que los idiomas se muestran por su propio nombre: quien busca el francés reconoce «Français», no «Francés». No uses banderas: una bandera representa un país, no un idioma, y el español no pertenece a un solo país.
Servir los idiomas
La carpeta dist/empresa/browser tiene tres aplicaciones, y necesitamos dos cosas: que una URL de Angular como /en/departamentos reciba el index.html de su idioma, y decidir qué hacer con quien entra en /. Lo normal es mirar la cabecera Accept-Language, que el navegador envía con los idiomas preferidos del usuario. Este servidor hace las dos cosas para probarlo en tu equipo, sin dependencias:
// servidor.mjs: sirve la compilación por idiomas, solo para probarla en tu equipo
import { createReadStream, existsSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { extname, join } from 'node:path';
const RAIZ = 'dist/empresa/browser';
const IDIOMAS = ['es', 'en', 'fr'];
const POR_DEFECTO = 'es';
const PUERTO = Number(process.env.PORT ?? 8080);
const TIPOS = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.webmanifest': 'application/manifest+json',
'.png': 'image/png',
'.ico': 'image/x-icon',
};
// "en-GB,en;q=0.9,es;q=0.8" => ['en', 'en', 'es']
function idiomaPreferido(cabecera = '') {
const pedidos = cabecera.split(',').map((p) => p.trim().slice(0, 2).toLowerCase());
return pedidos.find((i) => IDIOMAS.includes(i)) ?? POR_DEFECTO;
}
createServer((peticion, respuesta) => {
const { pathname } = new URL(peticion.url, 'http://servidor');
const idioma = pathname.split('/')[1];
// Sin idioma en la URL: elegimos uno y redirigimos
if (!IDIOMAS.includes(idioma)) {
const destino = idiomaPreferido(peticion.headers['accept-language']);
respuesta.writeHead(302, { Location: `/${destino}${pathname}`, Vary: 'Accept-Language' });
respuesta.end();
return;
}
let fichero = join(RAIZ, pathname);
if (!existsSync(fichero) || !statSync(fichero).isFile()) {
if (extname(pathname)) {
respuesta.writeHead(404);
respuesta.end('No encontrado');
return;
}
// Una ruta de Angular, como /en/departamentos: se entrega el index de SU idioma
fichero = join(RAIZ, idioma, 'index.html');
}
const tipo = TIPOS[extname(fichero)] ?? 'application/octet-stream';
respuesta.writeHead(200, { 'Content-Type': tipo, 'Cache-Control': 'no-cache' });
createReadStream(fichero).pipe(respuesta);
}).listen(PUERTO, '127.0.0.1', () => console.log(`http://127.0.0.1:${PUERTO}/`));
ng build
node servidor.mjs
Con un navegador en francés, al abrir http://127.0.0.1:8080/departamentos se redirige a /fr/departamentos, y el enlace «English» lleva a /en/departamentos, en la misma pantalla. La cabecera Vary avisa a las cachés intermedias de que la respuesta depende del idioma, para que no entreguen a todos la misma redirección.
En producción harás lo mismo con Nginx, que verás en el módulo 27. Esta configuración es la equivalente:
map $http_accept_language $idioma {
default es;
~^en en;
~^fr fr;
}
server {
listen 80;
root /usr/share/nginx/html;
absolute_redirect off;
location = / {
add_header Vary Accept-Language;
return 302 /$idioma/;
}
location ~ /(ngsw\.json|index\.html)$ {
add_header Cache-Control "no-cache";
}
location ~ ^/(es|en|fr)/ {
try_files $uri /$1/index.html;
}
}
La hemos probado en un contenedor con la imagen nginx:alpine:
Accept-Language: fr-FR,fr;q=0.9 -> 302 /fr/
Sin cabecera -> 302 /es/
GET /en/departamentos -> 200, con base href="/en/"
map convierte la cabecera en la variable $idioma, y try_files entrega el fichero si existe o, si no, el index.html de la carpeta capturada en $1. Las reglas con expresión regular se comprueban en orden, así que la de no-cache debe ir antes. Una diferencia con servidor.mjs: aquí, un .js que no existe también recibe index.html con código 200, en lugar de un 404.
💡 Consejo: Respeta la elección manual. Si alguien entra desde un navegador en español y pulsa «English», no lo devuelvas al español la próxima vez que abra
/. Esa decisión suele guardarse en una cookie que el servidor consulta antes queAccept-Language, y es lo que propone el ejercicio 10.
11. Traducir en ejecución
Una compilación por idioma tiene ventajas claras: máximo rendimiento y ningún coste por el idioma, y cada usuario descarga solo lo suyo. A cambio, cambiar de idioma recarga la página, y corregir una errata exige compilar y desplegar. Algunas aplicaciones necesitan otra cosa, por ejemplo cambiar de idioma sin perder lo que hay en la pantalla, o que el equipo de traducción publique cambios sin tocar el código.
Figura 5. Las dos formas de entregar traducciones.
| Criterio | En compilación (@angular/localize) | En ejecución (biblioteca) |
|---|---|---|
| Cambiar de idioma | Recarga la página | Inmediato |
| Rendimiento | Sin coste, el texto ya está incrustado | Busca cada texto y descarga un fichero extra |
| Despliegue | Una carpeta por idioma | Una sola aplicación |
| Corregir una traducción | Compilar y desplegar | Cambiar un JSON |
| Textos sin traducir | Se detectan al compilar | Se descubren al usar la aplicación |
| Varias zonas de idioma a la vez | No | Sí |
Las bibliotecas de la comunidad más conocidas son Transloco y ngx-translate. Trabajan con claves y ficheros JSON, y no forman parte de Angular, así que debes comprobar que su versión es compatible con la tuya.
@angular/localize también tiene un modo en ejecución, menos conocido y más limitado. Sirve para no compilar por idioma y cargar la traducción una sola vez, antes de arrancar:
// src/main.ts
/// <reference types="@angular/localize" />
import { registerLocaleData } from '@angular/common';
import { LOCALE_ID } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { loadTranslations } from '@angular/localize';
async function arrancar() {
const idioma = localStorage.getItem('idioma') ?? 'es';
if (idioma === 'en') {
const respuesta = await fetch('i18n/en.json');
const { translations } = await respuesta.json();
loadTranslations(translations);
}
const datos = idioma === 'en'
? await import('@angular/common/locales/en')
: await import('@angular/common/locales/es');
registerLocaleData(datos.default);
// La aplicación se importa DESPUÉS de cargar las traducciones
const { App } = await import('./app/app');
const { appConfig } = await import('./app/app.config');
await bootstrapApplication(App, {
providers: [...appConfig.providers, { provide: LOCALE_ID, useValue: idioma }],
});
}
arrancar().catch((error) => console.error(error));
{
"locale": "en",
"translations": {
"directorio.titulo": "Employee directory",
"menu.empleados": "Employees",
"directorio.buscar": "Search",
"ruta.empleados": "Employees | EMPRESA"
}
}
Lo hemos probado con ese fichero, que solo traduce cuatro textos. Con idioma en en, la aplicación muestra Employee directory y Employees | EMPRESA en la pestaña, y los importes salen como €2,600.00. Los textos que faltan se quedan en español, y la consola los avisa uno a uno:
[warning] No translation found for "directorio.col.nombre" ("Nombre").
[warning] No translation found for "directorio.error" ("No se pudo cargar la lista de empleados").
Este fichero tiene el formato que produce ng extract-i18n --format json. Observa el orden del código: los mensajes se evalúan cuando se carga el módulo que los contiene. Un title: $localize... en las rutas se ejecuta al importar app.routes.ts. Si importas la aplicación con un import normal al principio de main.ts, los textos se resuelven antes de loadTranslations y salen sin traducir. Aquí no hay registro automático: LOCALE_ID y registerLocaleData vuelven a ser responsabilidad tuya.
Con esta técnica, cambiar de idioma también exige recargar, porque las plantillas se traducen una sola vez. Su ventaja es tener un solo despliegue. Para cambiar de idioma al instante, necesitas una biblioteca.
12. Qué es una PWA
Pasamos a la segunda parte del módulo. Una aplicación web progresiva (Progressive Web App, PWA) es una aplicación web que se comporta como una aplicación instalada: arranca sin red, se puede añadir al escritorio y puede recibir notificaciones. Sigue siendo una web. No pasa por una tienda, y quien abre el enlace con un navegador antiguo sigue viendo la web normal. Por eso se llama progresiva.
Se apoya en dos piezas:
| Pieza | Qué aporta |
|---|---|
| Service worker | Un programa que intercepta las peticiones de la página y puede responder desde una caché |
| Manifest web | Un JSON con el nombre, los iconos y la forma de mostrarse al instalarla |
Para EMPRESA tiene sentido: quien visita un almacén con mala cobertura puede consultar la plantilla sin esperar y sin ver la página de error del navegador.
El service worker
Un service worker es un JavaScript que el navegador instala y ejecuta en un hilo aparte, sin acceso al DOM. Actúa como un servidor intermedio que vive en el navegador: todas las peticiones de la página pasan por él, incluida la del propio index.html. Y sigue instalado cuando cierras la pestaña, lo que le permite responder en la siguiente visita aunque no haya red.
Figura 6. El service worker se coloca entre la página y la red.
Se suele comparar con un portero: la página pide algo, y él decide si lo entrega de la estantería (la caché) o si va a buscarlo fuera. Escribir este programa a mano es delicado, porque un error puede dejar a los usuarios atrapados en una versión antigua. Angular trae uno ya hecho, ngsw-worker.js (Angular Service Worker), que se configura con un JSON y no se programa.
⚠️ Importante: Los service workers solo funcionan en contextos seguros: HTTPS, además de
localhosty127.0.0.1para desarrollar. Si despliegas en HTTP o pruebas desde otro equipo con la IP de tu máquina, el navegador no los registrará, sin que la aplicación falle.
13. Añadir la PWA a Angular
ng add @angular/pwa
| Fichero | Cambio |
|---|---|
package.json | Añade @angular/service-worker |
angular.json | Añade "serviceWorker": "ngsw-config.json" a la configuración production |
src/app/app.config.ts | Añade provideServiceWorker |
src/index.html | Añade <link rel="manifest" href="manifest.webmanifest"> |
public/manifest.webmanifest | Nuevo: datos de instalación |
public/icons/ | Nuevo: ocho iconos de 72 a 512 píxeles |
ngsw-config.json | Nuevo: configuración del service worker |
Esta es la configuración, ya ordenada:
// src/app/app.config.ts
import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
],
};
enabled: !isDevMode() lo desactiva durante el desarrollo. Tiene dos motivos: ng serve no genera el fichero ngsw.json que el service worker necesita, y una caché entre tú y tu código te haría ver versiones antiguas al recargar. Por eso las PWA se prueban siempre con una compilación de producción.
registrationStrategy decide cuándo se registra. Instalar el service worker implica descargar ficheros, y eso no debe competir con el arranque de la aplicación:
| Estrategia | Cuándo se registra |
|---|---|
registerWhenStable:30000 (por defecto) | Cuando la aplicación está estable, o a los 30 segundos si no lo consigue antes |
registerImmediately | Enseguida |
registerWithDelay:5000 | Al cabo de 5 segundos |
Función que devuelve un Observable | Con el primer valor que emita |
💡 Consejo: Escribe siempre el tiempo en
registerWhenStable. Sin él, el código compara la estabilidad con un temporizador de cero milisegundos, y el registro se hace enseguida en lugar de esperar.
Lo que produce ng build
Además de la aplicación, cada compilación incluye estos ficheros:
| Fichero | Función |
|---|---|
ngsw-worker.js | El service worker de Angular |
ngsw.json | Manifiesto generado: qué ficheros hay en esta versión y sus hashes |
safety-worker.js | Un service worker de emergencia que se desinstala a sí mismo |
worker-basic.min.js | Copia del anterior, por compatibilidad |
ngsw.json es la clave de todo: lista cada fichero con el hash de su contenido. Cuando publicas una versión, el hash global cambia, y así el service worker sabe que hay novedades y qué ficheros debe descargar.
Probarlo en tu equipo
ng serve no vale, y ng build deja los ficheros en dist. Sirve esa carpeta con el servidor.mjs de la sección 10. Si tu aplicación no usa localize, cualquier servidor estático vale:
npx http-server dist/empresa/browser -p 8080
14. ngsw-config.json: los ficheros de la aplicación
Este es el fichero que genera ng add:
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}
index indica qué fichero se entrega ante una navegación. Las rutas se escriben respecto a la carpeta de salida, sin pensar en base href: el generador las adapta. La entrada /index.csr.html es para aplicaciones con SSR, que viste en el módulo 24.
Un grupo de recursos (assetGroups) es un conjunto de ficheros que forman parte de la versión publicada. Tienen dos opciones clave:
| Opción | Valor | Efecto |
|---|---|---|
installMode | prefetch | Descarga todo el grupo al instalar la versión. Es lo que permite arrancar sin red |
lazy | Guarda cada fichero la primera vez que alguien lo pide | |
updateMode | prefetch | Al llegar una versión, descarga los ficheros cambiados que ya tenía guardados |
lazy | Espera a que se pidan |
Por eso, el grupo app usa prefetch: sin main.js o sin index.html, nada funciona. El grupo assets usa lazy porque las imágenes pueden pesar mucho, y descargarlas todas castigaría a quien no va a verlas.
⚠️ Importante: Un fichero que el usuario no ha pedido y está en un grupo
lazyno existirá sin conexión. Una imagen o una fuente imprescindible debe ir enprefetch. La carga perezosa del router no te afecta: los chunks son ficheros.js, así que entran en el grupoappy se descargan al instalar. En nuestra compilación,chunk-PZXAWPPY.js, el de departamentos, aparece en ese grupo.
El generador convierte esta configuración en el ngsw.json de cada compilación, y ahí ves exactamente qué se guarda. Este fragmento es real:
/en/index.html
app (prefetch): /en/chunk-C4DTFG27.js, /en/chunk-PZXAWPPY.js, /en/favicon.ico,
/en/index.html, /en/main-NEODMM5K.js, /en/manifest.webmanifest,
/en/polyfills-B6AWBRSW.js, /en/styles-YHE6ASLO.css
assets (lazy): /en/icons/icon-128x128.png, /en/icons/icon-144x144.png, ...
Fíjate en el prefijo /en/: cada idioma tiene su ngsw.json. Lo retomaremos en la sección 20.
Navegaciones
Si escribes /es/departamentos en la barra de direcciones, ese fichero no existe, pero el service worker lo reconoce como una navegación y entrega index.html. Es lo que permite recargar en cualquier ruta sin conexión.
Los valores por defecto de navigationUrls excluyen las URL que parecen ficheros (con un punto en el último segmento) y las que contienen __, que se usan a menudo para rutas internas de autenticación o del servidor. Si tienes rutas que debe atender el servidor, como /informes/**, añade una exclusión:
"navigationUrls": [
"/**",
"!/**/*.*",
"!/**/*__*",
"!/**/*__*/**",
"!/informes/**"
]
15. dataGroups: las respuestas de la API
Con assetGroups, la aplicación arranca sin red, pero se queda sin datos. Para guardar respuestas de la API están los grupos de datos. Se diferencian de los anteriores en un punto esencial: no forman parte de la versión. Cuando publicas otra versión, los datos guardados se conservan y no se comparan con ningún hash, porque el generador no los conoce. Cada grupo decide cuánto vive una respuesta.
{
"dataGroups": [
{
"name": "catalogos",
"urls": ["http://localhost:3000/api/departamentos"],
"cacheConfig": {
"strategy": "performance",
"maxSize": 10,
"maxAge": "1h"
}
},
{
"name": "empleados",
"urls": ["http://localhost:3000/api/empleados**"],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 30,
"maxAge": "1d",
"timeout": "3s"
}
}
]
}
Figura 7. Las dos estrategias.
| Opción | Significado |
|---|---|
urls | Patrones de URL. Escribe las de otro origen completas, con protocolo y puerto, para no coincidir por accidente |
version | Número del grupo. Súbelo si cambia el formato de la API, para descartar lo guardado |
maxSize | Máximo de respuestas guardadas. Al superarlo, expulsa la que lleva más tiempo sin usarse |
maxAge | Duración de una respuesta: 3d12h, 30m, 10s (d, h, m, s, u para milisegundos) |
strategy | performance (por defecto) o freshness |
timeout | Solo en freshness: tiempo de espera a la red antes de usar la caché |
refreshAhead | En performance, refresca en segundo plano una respuesta que está a punto de caducar |
cacheOpaqueResponses | Permite guardar respuestas opacas, de otro origen sin CORS |
cacheQueryOptions | { "ignoreSearch": true } ignora la cadena de consulta al buscar en la caché |
📘 Concepto: Cada patrón de
urlsse convierte en una expresión regular sin anclar: basta con que aparezca en algún punto de la URL. El generador producehttp:\/\/localhost:3000\/api\/departamentos, sin^ni$, de modo que también coincide con/api/departamentos/10y con?pagina=2. Nosotros lo hemos comprobado sobre elngsw.jsongenerado. El**final del grupoempleadosno cambia nada: solo deja claro que incluye sus búsquedas, porquehttpResourceañade siempre?q=...&tamano=.... La URL completa, consulta incluida, es la clave de la caché, así que cada búsqueda es una entrada distinta.
Elegir es una decisión del negocio, y la pregunta correcta es qué es peor en cada pantalla: esperar o ver un dato antiguo.
performanceresponde desde la caché sin tocar la red hasta que caduca. Va muy bien para datos que cambian poco. DurantemaxAge, un departamento nuevo no aparecerá.freshnessintenta primero la red, y solo trastimeouto un fallo usa la copia. Con la red lenta, cada petición tarda hasta 3 segundos. Si no hay red, la respuesta es inmediata, porque el fallo se produce enseguida.
Las escrituras no se guardan. Las peticiones GET y HEAD siguen la estrategia del grupo. Las demás, como POST o DELETE, van directas a la red, y además borran de la caché la entrada de esa misma URL exacta. Si haces DELETE /api/empleados/100, la lista /api/empleados?q= no se entera: seguirá guardada y puede mostrar un empleado borrado hasta que se refresque.
⚠️ Importante: Lo que guardas en
dataGroupsqueda en el disco del usuario, fuera del control de tu sesión. Cerrar sesión no lo borra. No metas en estos grupos respuestas con datos personales o privados en una aplicación que se use en equipos compartidos, y no incluyas nunca las rutas de autenticación. Los salarios de nuestro ejemplo solo están en una demostración.
⚠️ Importante: Sin conexión, la cola de escrituras no existe. Un
POSThecho sin red simplemente falla: el service worker de Angular no lo guarda para reintentarlo. Es tu aplicación quien debe mostrar el error o implementar una cola propia.
16. Probar el modo sin conexión
El panel Application de las herramientas de desarrollo tiene todo lo necesario: la pestaña Service workers muestra el estado y permite activar Offline, y Cache storage enseña lo guardado.
Hazlo en este orden, que es el que suele fallar:
- Compila y sirve:
ng buildynode servidor.mjs. - Abre
http://127.0.0.1:8080/es/. En esta primera visita, el service worker se registra, pero aún no controla la página. - Recarga. Ahora sí pasa todo por él, y las peticiones a la API empiezan a guardarse.
- Visita las pantallas que quieras tener disponibles.
- Activa Offline en el panel, o detén el servidor y la API, y recarga.
Hemos automatizado esos pasos con Playwright, que verás en el módulo 26, usando context.setOffline(true). Así se ve la aplicación recargada sin red:

Figura 8. Página recargada sin red. El HTML, el JavaScript y los datos vienen del service worker.
Los resultados de la prueba fueron estos:
| Sin conexión, el usuario… | Resultado | Por qué |
|---|---|---|
Recarga /es/empleados | Funciona, con 5 filas de 24 empleados | Respuesta guardada por freshness |
| Va a Departamentos | Funciona, con 10 departamentos | Pantalla visitada antes, guardada por performance |
Busca garcia por primera vez | Muestra el mensaje de error | Esa URL nunca se pidió y no hay copia |
Abre /en/empleados | Error del navegador ERR_INTERNET_DISCONNECTED | El service worker de /es/ no controla /en/ |

Figura 9. Una búsqueda que nunca se hizo con red. La aplicación sigue viva y puede explicar lo que ocurre.
La tercera fila es muy instructiva. La aplicación sin conexión no es la aplicación con datos: solo tienes lo ya visitado. Hay que diseñar las pantallas pensando en el estado de error, y aquí es donde brillan los recursos del módulo 13. Sin el @if (lista.error()) de esta plantilla, la aplicación mostraría una pantalla vacía:
<!-- src/app/directorio/directorio.html (fragmento) -->
@if (lista.error()) {
<p class="error">{{ mensajeError }}</p>
} @else if (lista.hasValue()) {
@let total = lista.value().total;
<p class="total" i18n="@@directorio.total">
{total, plural,
=0 {No hay empleados}
=1 {Hay un empleado}
other {Hay {{ total }} empleados}
}
</p>
<!-- tabla -->
}
// src/app/directorio/directorio.ts
import { CurrencyPipe, DatePipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, signal } from '@angular/core';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';
@Component({
selector: 'app-directorio',
imports: [CurrencyPipe, DatePipe],
templateUrl: './directorio.html',
})
export class Directorio {
protected readonly empresa = 'EMPRESA';
protected readonly hoy = new Date(2026, 10, 2);
protected readonly busqueda = signal('');
protected readonly lista = httpResource<Pagina<Empleado>>(() => ({
url: `${API}/empleados`,
params: { q: this.busqueda(), tamano: 5, orden: 'apellidos' },
}));
protected readonly mensajeError = $localize`:Aviso cuando falla la API@@directorio.error:No se pudo cargar la lista de empleados`;
protected buscar(evento: Event) {
this.busqueda.set((evento.target as HTMLInputElement).value);
}
}
Detectar la conexión con una señal 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: en lugar de
@Service(), escribe@Injectable({ providedIn: 'root' }), importado de@angular/core; todo lo demás funciona igual.
El navegador expone navigator.onLine y dos eventos, online y offline. Convertirlos en señal, como en el módulo 08, permite usarlos en cualquier plantilla:
// src/app/pwa/conexion.ts
import { DestroyRef, Service, inject, signal } from '@angular/core';
@Service()
export class Conexion {
private readonly estado = signal(navigator.onLine);
readonly enLinea = this.estado.asReadonly();
constructor() {
const actualizar = () => this.estado.set(navigator.onLine);
window.addEventListener('online', actualizar);
window.addEventListener('offline', actualizar);
inject(DestroyRef).onDestroy(() => {
window.removeEventListener('online', actualizar);
window.removeEventListener('offline', actualizar);
});
}
}
⚠️ Importante:
navigator.onLinesolo indica si el equipo tiene alguna red. Con wifi conectada a un router sin Internet, dicetrue. Úsalo para informar al usuario, no para decidir si haces una petición: la única prueba real de que hay conexión es que la petición funcione.
Depurar el service worker
El service worker responde a una URL especial, ngsw/state, que no llega al servidor. Se abre dentro de su ámbito, en nuestro caso http://127.0.0.1:8080/es/ngsw/state:
NGSW Debug Info:
Driver version: 22.2.4
Driver state: NORMAL ((nominal))
Latest manifest hash: 0d25feed989b63448a162eabe3c755f181682631
Last update check: 3s880u
=== Version 0d25feed989b63448a162eabe3c755f181682631 ===
Clients: 6aca447c-0109-43a3-8967-baecbcdec22a
=== Idle Task Queue ===
Last update tick: 1s626u
Last update run: never
Muestra el hash de la versión activa, qué pestañas (clients) la usan y las tareas pendientes. Driver state es lo primero que debes mirar. Los otros valores posibles son EXISTING_CLIENTS_ONLY y SAFE_MODE, e indican que el service worker se ha degradado por un error, con el motivo entre paréntesis. Más abajo, la sección Debug log recoge los últimos errores.
Las cachés también se pueden listar en la consola con await caches.keys(). Sus nombres revelan toda la organización:
ngsw:/es/:db:control
ngsw:/es/:0d25feed989b63448a162eabe3c755f181682631:assets:app:cache
ngsw:/es/:0d25feed989b63448a162eabe3c755f181682631:assets:assets:cache
ngsw:/es/:1:data:catalogos:cache
ngsw:/es/:1:data:empleados:cache
Los ficheros van por hash de versión. Los datos van por grupo y por su campo version, que es el 1 del nombre. Por eso sobreviven a un despliegue.
Saltarse el service worker
A veces una petición concreta debe ir siempre a la red. El service worker la deja pasar si la URL lleva el parámetro ngsw-bypass, o si la petición lleva una cabecera con ese nombre. Con una API de otro origen, usa el parámetro: una cabecera propia provoca una comprobación CORS, y la API tendría que permitirla.
this.http.get(`${API}/empleados`, { params: { q: 'marta', 'ngsw-bypass': 'true' } });
17. Versiones nuevas con SwUpdate
Aquí está la gran diferencia con una web normal, y su principal trampa. Sin service worker, recargar trae lo último. Con él, recargar entrega la versión guardada, porque la página se sirve antes de preguntar al servidor. Mientras tanto, el service worker comprueba en segundo plano si hay novedades, y las descarga. Si no haces nada, el usuario verá la versión nueva en la visita siguiente.
Figura 10. Cómo llega una versión nueva.
La comprobación ocurre al iniciarse el service worker, poco después de una navegación. SwUpdate te permite enterarte y actuar:
| Miembro | Qué hace |
|---|---|
isEnabled | true si hay service worker activo. Es false en ng serve |
versionUpdates | Observable de eventos del ciclo de versiones |
unrecoverable | Observable que avisa de un estado irrecuperable |
checkForUpdate() | Comprueba ahora. La promesa da true si hay una versión nueva lista |
activateUpdate() | Asigna a esta pestaña la última versión. La promesa da true si cambió |
Evento de versionUpdates | Significado |
|---|---|
VERSION_DETECTED | Hay una versión nueva y empieza a descargarse |
VERSION_READY | Ya está descargada entera, con currentVersion y latestVersion |
VERSION_INSTALLATION_FAILED | Falló la descarga. Incluye error |
NO_NEW_VERSION_DETECTED | Se ha comprobado y no hay nada nuevo |
Un servicio que avisa al usuario 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: en lugar de
@Service(), escribe@Injectable({ providedIn: 'root' });SwUpdatefunciona exactamente igual.
// src/app/pwa/actualizaciones.ts
import { ApplicationRef, DOCUMENT, Service, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SwUpdate, VersionEvent } from '@angular/service-worker';
const SEIS_HORAS = 6 * 60 * 60 * 1000;
@Service()
export class Actualizaciones {
private readonly sw = inject(SwUpdate);
private readonly appRef = inject(ApplicationRef);
private readonly documento = inject(DOCUMENT);
private readonly nueva = signal(false);
private readonly rota = signal(false);
readonly hayVersionNueva = this.nueva.asReadonly();
readonly versionRota = this.rota.asReadonly();
constructor() {
if (!this.sw.isEnabled) {
return; // en ng serve, o en un navegador sin service worker
}
this.sw.versionUpdates
.pipe(takeUntilDestroyed())
.subscribe((evento) => this.alRecibir(evento));
this.sw.unrecoverable.pipe(takeUntilDestroyed()).subscribe(() => this.rota.set(true));
void this.comprobarPeriodicamente();
}
private alRecibir(evento: VersionEvent) {
switch (evento.type) {
case 'VERSION_DETECTED':
console.log('Descargando la versión', evento.version.hash);
break;
case 'VERSION_READY':
console.log('Lista la versión', evento.latestVersion.hash);
this.nueva.set(true);
break;
case 'VERSION_INSTALLATION_FAILED':
console.error('Falló la versión', evento.version.hash, evento.error);
break;
case 'NO_NEW_VERSION_DETECTED':
break;
}
}
private async comprobarPeriodicamente() {
await this.appRef.whenStable();
setInterval(() => void this.sw.checkForUpdate(), SEIS_HORAS);
}
async aplicar() {
await this.sw.activateUpdate();
this.documento.location.reload();
}
}
Tres detalles importantes:
VERSION_READYy noVERSION_DETECTED. Avisar antes de que acabe la descarga llevaría al usuario a recargar en una versión a medias.whenStableantes del temporizador.comprobarPeriodicamenteespera a que termine el arranque, para no interferir con el registro del service worker, ligado también a la estabilidad.activateUpdatemásreload. Después de activar, la página ejecuta el JavaScript antiguo, pero los ficheros se sirven de la versión nueva. Un chunk perezoso que aún no se hubiera cargado ya no existiría. Recargar lo deja todo coherente.
Lo hemos comprobado con dos versiones reales. Con la primera instalada y el service worker controlando la página, publicamos en el servidor la segunda, en la que el título termina en v2, y recargamos:
v1: Directorio de empleados
tras publicar y recargar: Directorio de empleados
consola: Descargando la versión 51426e7a9b9bf7561066c5a88069a18d50030270
consola: Lista la versión 51426e7a9b9bf7561066c5a88069a18d50030270
aviso a los 6248 ms
tras pulsar Actualizar: Directorio de empleados v2
La recarga sigue mostrando el título antiguo, que es justo la trampa de la que hablábamos. El aviso aparece unos segundos después, porque el service worker espera a que la actividad de la página se calme antes de comprobar. En ese momento, ngsw/state lista las dos versiones a la vez, con la pestaña todavía asignada a la antigua.

Figura 11. Aviso real tras detectar la versión nueva. Hasta que no se pulsa el botón, el usuario sigue en la versión instalada.
💡 Consejo: Si no quieres interrumpir, deja el aviso discreto y aplica la versión sola en la siguiente visita, que es lo que ocurre sin hacer nada. Evita recargar sin preguntar: el usuario podría estar rellenando un formulario.
Estados irrecuperables
A veces la versión que usa una pestaña ya no se puede completar. Suele ocurrir si el navegador borró parte de la caché por falta de espacio y, cuando el service worker va a recuperar un fichero, el servidor ya no lo tiene, porque desplegaste otra versión. La aplicación no puede seguir, y unrecoverable emite un evento con el campo reason. Solo cabe avisar y recargar, que es lo que hace el primer @if de app.html.
Lo provocamos sin querer al preparar este módulo: sustituimos la carpeta del servidor mientras el service worker aún estaba descargando la primera versión. Recibió un 404 en /es/index.html, descartó esa versión, y en el registro de ngsw/state apareció Failed to retrieve hashed resource from the server. Ocurre igual en producción si un despliegue borra los ficheros antiguos mientras hay usuarios instalando. Por eso conviene publicar los ficheros nuevos primero, y dejar ngsw.json para el final.
Retirar un service worker
Un service worker defectuoso no se arregla borrando el fichero, porque los usuarios siguen teniéndolo instalado. Si necesitas desactivarlo, publica safety-worker.js con el nombre ngsw-worker.js, en la misma ruta. El navegador detecta el cambio, instala este sustituto, y el sustituto se da de baja a sí mismo y borra las cachés. Es tu salida de emergencia, y viene incluida en cada compilación.
⚠️ Importante: Sirve
ngsw.jsonyindex.htmlsin caché larga en el servidor. Si una CDN los guarda un día, tus usuarios tardarán un día en descubrir las versiones nuevas. Elservidor.mjsenvíaCache-Control: no-cache.
18. El manifest web
public/manifest.webmanifest describe la aplicación para cuando se instala. Esta es la nuestra, ya adaptada:
{
"name": "Gestión EMPRESA",
"short_name": "EMPRESA",
"description": "Directorio de empleados y departamentos",
"lang": "es",
"display": "standalone",
"scope": "./",
"start_url": "./",
"theme_color": "#283593",
"background_color": "#ffffff",
"icons": [
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}
| Campo | Qué controla |
|---|---|
name y short_name | Nombre completo, y nombre corto bajo el icono |
display | standalone abre sin barra de direcciones. También fullscreen, minimal-ui y browser |
start_url | Con qué URL arranca. Relativa al propio manifest |
scope | Qué URL pertenecen a la aplicación instalada |
theme_color y background_color | Color de la barra del sistema, y fondo de la pantalla de arranque |
icons | Iconos. purpose: maskable admite recorte con la forma del sistema |
Los iconos generados llevan el logotipo de Angular: sustitúyelos por los tuyos conservando los nombres. Las rutas relativas son una buena elección, porque el mismo manifest funciona en /es/, /en/ y /fr/.
Chrome confirma que cumple los requisitos. Al consultar la instalabilidad de nuestra aplicación mediante su protocolo de depuración, Page.getInstallabilityErrors devuelve una lista vacía: {"installabilityErrors":[]}.
Con el manifest y el service worker en HTTPS, el navegador ofrecerá instalar la aplicación. Cómo lo hace depende de cada uno. Los navegadores basados en Chromium muestran un icono en la barra de direcciones y emiten el evento beforeinstallprompt, con el que puedes poner tu propio botón, como propone el ejercicio 8. Safari y Firefox tienen sus propios caminos, y ese evento no forma parte de ningún estándar: trátalo siempre como una mejora opcional.
19. Notificaciones push con SwPush 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: en lugar de
@Service(), escribe@Injectable({ providedIn: 'root' });SwPushfunciona igual.
El service worker sigue vivo con la aplicación cerrada, así que puede recibir mensajes del servidor y mostrar notificaciones. Hace falta una pieza que EMPRESA no tiene: un servidor que guarde las suscripciones y envíe los mensajes a través del servicio push del navegador, con un par de claves VAPID.
// src/app/pwa/avisos.ts
import { HttpClient } from '@angular/common/http';
import { Service, inject } from '@angular/core';
import { SwPush } from '@angular/service-worker';
import { firstValueFrom } from 'rxjs';
import { API } from '../api';
@Service()
export class Avisos {
private readonly push = inject(SwPush);
private readonly http = inject(HttpClient);
readonly mensajes = this.push.messages;
readonly clics = this.push.notificationClicks;
async suscribirse(clavePublica: string) {
if (!this.push.isEnabled) {
return;
}
const suscripcion = await this.push.requestSubscription({ serverPublicKey: clavePublica });
await firstValueFrom(this.http.post(`${API}/suscripciones`, suscripcion));
}
}
requestSubscription muestra el permiso del navegador y devuelve una PushSubscription, que hay que enviar al servidor. La ruta /suscripciones no existe en la API EMPRESA: este código compila, pero no se ha probado con un servidor push real, porque requiere uno. El service worker solo muestra una notificación si el mensaje tiene un objeto notification con title:
{
"notification": {
"title": "Pedido 5001 enviado",
"body": "Ha salido del almacén de Madrid"
}
}
💡 Consejo: Pide el permiso cuando el usuario haga algo que lo justifique, por ejemplo pulsar «Avisarme», y nunca al abrir la página. Un rechazo es difícil de revertir, porque debe cambiarlo el usuario en los ajustes del navegador.
20. Los dos temas juntos
Combinar localize y serviceWorker funciona sin configuración extra, y el resultado merece una explicación. Cada carpeta de idioma recibe sus propios ficheros:
browser/
en/ index.html main-NEODMM5K.js manifest.webmanifest ngsw.json ngsw-worker.js ...
es/ index.html main-NEODMM5K.js manifest.webmanifest ngsw.json ngsw-worker.js ...
fr/ index.html main-NEODMM5K.js manifest.webmanifest ngsw.json ngsw-worker.js ...
provideServiceWorker('ngsw-worker.js') usa una ruta relativa, que se resuelve desde base href. Así, en /en/ se registra /en/ngsw-worker.js. Un service worker solo controla las URL que cuelgan de su carpeta (su ámbito, scope), de modo que cada idioma tiene su propio service worker, con sus cachés y sus datos. Son independientes, y de ahí salen varias consecuencias:
- Quien solo usa
/es/no descarga nada de los demás idiomas. Es exactamente lo que queremos. - Si no ha visitado
/en/, ese idioma no existe sin conexión, como comprobó la última fila de la tabla de la sección 16. - Las cachés no se comparten: la lista de empleados se guarda aparte en cada idioma.
- Al cambiar de idioma por primera vez, el navegador registra otro service worker.
El manifest es el mismo fichero para todos, porque public/ se copia sin traducir. name queda en español en las tres versiones, aunque start_url sí apunta a cada idioma, al ser relativa. Si necesitas nombres traducidos, usa un nombre neutro, como una marca, o genera un manifest por idioma con un script tras la compilación.
💡 Consejo: Si tu aplicación usa SSR, añade
withI18nSupport()aprovideClientHydrationpara que la hidratación incluya los bloquesi18n, como complemento de lo que viste en el módulo 24.
Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
Cannot find name '$localize' | No se ha ejecutado ng add @angular/localize | Ejecútalo; añade los tipos y el polyfill |
Sale $localize is not defined en el navegador | Falta @angular/localize/init en polyfills | Añádelo en angular.json |
La pantalla en blanco en /en/, con errores 404 de main-...js | El servidor no entrega la carpeta de ese idioma o base href es incorrecto | Revisa subPath y la regla del servidor |
/en/departamentos da 404 al recargar | El servidor no devuelve el index.html del idioma | Usa try_files $uri /$1/index.html o equivalente |
| Se mezclan idiomas en una pantalla | Unidades sin <target> o textos sin marcar | Revisa los ficheros, y busca aria-label, title y ternarios |
Los textos de rutas quedan en el idioma original con loadTranslations | La aplicación se importó antes de cargar las traducciones | Usa import() dinámico tras loadTranslations |
SwUpdate.isEnabled es false | Estás en ng serve, sin HTTPS o el navegador no lo admite | Prueba con ng build y un servidor |
| Al desplegar, nadie ve la versión nueva | ngsw.json en caché larga, o no hay aviso | no-cache para ngsw.json, y usa SwUpdate |
| Primera visita sin datos sin conexión | El service worker aún no controlaba la página | Recarga una vez con red |
| Una imagen falla sin conexión | Grupo lazy, nunca solicitada | Pásala a prefetch |
| La lista sigue mostrando un dato borrado | performance y entrada que no se invalida | Usa freshness o reduce maxAge |
| La API no se guarda | Otro puerto u origen en urls, o se prueba la primera visita | Compara urls con la URL real y recarga con red |
21. Ejercicios
Ejercicio 1: Traducir el pie de página
Crea un componente Pie con un aviso legal que incluya el año en una interpolación, y un enlace «Privacidad» con un title. Márcalo todo para traducir, con identificadores propios y una descripción para el aviso legal.
Ver solución
import { Component } from '@angular/core';
@Component({
selector: 'app-pie',
template: `
<footer>
<p i18n="Pie de página|Aviso legal bajo cada pantalla@@pie.legal">
© {{ anio }} EMPRESA S.A. Todos los derechos reservados.
</p>
<a
href="/privacidad"
title="Lee cómo tratamos tus datos"
i18n-title="@@pie.privacidad.ayuda"
i18n="Enlace del pie@@pie.privacidad"
>Privacidad</a>
</footer>
`,
})
export class Pie {
protected readonly anio = 2026;
}
El enlace necesita dos marcas: i18n para su contenido e i18n-title para el atributo. Son dos unidades distintas. La frase del aviso se marca entera, con la interpolación dentro, para que cada idioma coloque el año donde le corresponda.
Ejercicio 2: Plural y select anidados
Crea un componente ResumenPedidos que reciba pendientes, y muestre «Sin pedidos pendientes», «Tienes un pedido pendiente» o «Tienes 7 pedidos pendientes». Añade otra línea que reciba genero y personas, y concuerde: «Una directora», «2 directoras», «Un director», «3 directores», y «Una persona» o «5 personas» si no se indica género.
Ver solución
import { Component, input } from '@angular/core';
@Component({
selector: 'app-resumen-pedidos',
template: `
<p i18n="@@pedidos.resumen">
{pendientes(), plural,
=0 {Sin pedidos pendientes}
=1 {Tienes un pedido pendiente}
other {Tienes {{ pendientes() }} pedidos pendientes}
}
</p>
<p i18n="@@equipo.resumen">
{genero(), select,
mujer {{personas(), plural, =1 {Una directora} other {{{ personas() }} directoras}}}
hombre {{personas(), plural, =1 {Un director} other {{{ personas() }} directores}}}
other {{personas(), plural, =1 {Una persona} other {{{ personas() }} personas}}}
}
</p>
`,
})
export class ResumenPedidos {
readonly pendientes = input.required<number>();
readonly genero = input<string>('otro');
readonly personas = input(1);
}
Salida real con [pendientes] a 0, 1 y 7:
Sin pedidos pendientes Una persona
Tienes un pedido pendiente 2 directoras
Tienes 7 pedidos pendientes Un director
La segunda fila usa genero="mujer" y [personas]="2", y la tercera, genero="hombre".
Las llaves triples de {{{ personas() }} directoras} no son una sintaxis especial: la primera abre el caso y las dos siguientes abren la interpolación.
Ejercicio 3: Predecir el plural
Sin ejecutar nada, responde: ¿qué muestra esta unidad en francés con total igual a 0, 1 y 2? Si borraras el caso =0, ¿qué se vería con 0? Comprueba después tu respuesta.
{total, plural, =0 {Aucun employé} one {{{total}} employé} other {{{total}} employés}}
Ver solución
Con 0 muestra «Aucun employé», porque los casos exactos tienen prioridad sobre las categorías. Con 1 muestra «1 employé». Con 2, «2 employés».
Sin el caso =0, se vería «0 employé», en singular y sin ningún error, porque en francés new Intl.PluralRules('fr').select(0) devuelve one. Es correcto en francés, y con la misma regla en español la salida sería other, es decir, «0 empleados».
Ejercicio 4: Cifras compactas
Crea un pipe compacta que formatee números grandes de forma abreviada según LOCALE_ID, sin registrar datos adicionales. Pruébalo con 1234567 en los tres idiomas, junto con 1234.5 | currency, usando el euro como moneda por defecto con DEFAULT_CURRENCY_CODE.
Ver solución
import { CurrencyPipe } from '@angular/common';
import { Component, DEFAULT_CURRENCY_CODE, LOCALE_ID, Pipe, PipeTransform, inject } from '@angular/core';
@Pipe({ name: 'compacta' })
export class CompactaPipe implements PipeTransform {
private readonly formato = new Intl.NumberFormat(inject(LOCALE_ID), {
notation: 'compact',
maximumFractionDigits: 1,
});
transform(valor: number): string {
return this.formato.format(valor);
}
}
@Component({
selector: 'app-cifras',
imports: [CompactaPipe, CurrencyPipe],
providers: [{ provide: DEFAULT_CURRENCY_CODE, useValue: 'EUR' }],
template: `<p>{{ 1234567 | compacta }} / {{ 1234.5 | currency }} / {{ 1234.5 | currency: 'USD' }}</p>`,
})
export class Cifras {}
Salida real en cada idioma:
es: 1,2 M / 1.234,50 € / 1.234,50 US$
en: 1.2M / €1,234.50 / $1,234.50
fr: 1,2 M / 1 234,50 € / 1 234,50 $US
Intl no necesita datos de Angular, porque los trae el navegador. Crear el formato en un campo evita construirlo por cada valor: Intl.NumberFormat es relativamente costoso. Sin DEFAULT_CURRENCY_CODE, currency usaría USD, sea cual sea el idioma.
Ejercicio 5: Detectar lo que falta antes de desplegar
Haz que la integración continua falle si alguien añade un texto y olvida traducirlo. Indica qué cambias en angular.json, y explica qué caso no detecta este método.
Ver solución
"options": {
"i18nMissingTranslation": "error"
}
Si el texto nuevo se marcó con i18n y su unidad no existe en messages.fr.xlf, ng build falla. Hay dos casos que no detecta:
- Una unidad copiada que existe sin
<target>: se acepta y sale el texto original, como vimos en la sección 7. - Un texto que nadie marcó con
i18n. Ninguna herramienta sabe que debería traducirse.
Para el primero, puedes añadir una comprobación propia que busque unidades sin <target>. Para el segundo, nada sustituye a revisar la aplicación en cada idioma.
Ejercicio 6: Un grupo para los productos
Añade a ngsw-config.json un grupo que guarde durante 12 horas las respuestas del catálogo de productos con sus filtros, hasta 40 respuestas, primero desde la caché, y que las refresque en segundo plano cuando les queden menos de 2 horas.
Ver solución
{
"name": "productos",
"urls": ["http://localhost:3000/api/productos**"],
"version": 1,
"cacheConfig": {
"strategy": "performance",
"maxSize": 40,
"maxAge": "12h",
"refreshAhead": "2h"
}
}
Lo hemos validado con el generador: maxAge queda en 43 200 000 ms y refreshAheadMs en 7 200 000. El patrón resultante, http:\/\/localhost:3000\/api\/productos[^/]*[^/]*, no está anclado, así que coincide con /api/productos, con /api/productos?disponibles=true y también con /api/productos/1. Si no quieres guardar el detalle, añade la URL a otro grupo anterior: gana el primer grupo que coincide.
Ejercicio 7: Una petición que nunca sale de la caché
La pantalla de saldos necesita un dato que nunca debe servirse desde una copia, aunque su URL coincida con un dataGroup. Haz una petición a la lista de empleados que se salte el service worker, y comprueba qué ocurre con y sin conexión.
Ver solución
protected sinCache() {
this.http
.get(`${API}/empleados`, { params: { q: 'marta', 'ngsw-bypass': 'true' } })
.subscribe({ next: () => this.resultado.set('ok'), error: () => this.resultado.set('fallo') });
}
Resultado real: con red, la petición funciona, y al listar la caché ngsw:/es/:1:data:empleados:cache solo aparecen las búsquedas normales, sin la de marta. Sin red, la petición falla y el error deja fallo en resultado, aunque otras URL del mismo grupo sigan respondiendo desde la caché.
No sirve la cabecera ngsw-bypass con esta API, porque se trata de otro origen: el navegador haría una comprobación CORS, y la API solo permite Content-Type y Authorization.
Ejercicio 8 (propuesto): Botón para instalar
Crea un servicio Instalacion que capture beforeinstallprompt, cancele el aviso por defecto con preventDefault, y exponga una señal sePuedeInstalar. Añade un botón que llame a prompt() y oculte el botón cuando se reciba appinstalled. TypeScript no incluye el tipo de ese evento: declara tú una interfaz con prompt(). Comprueba que en un navegador sin este evento la aplicación sigue funcionando.
Ejercicio 9 (propuesto): Aviso de datos guardados
Usa Conexion para mostrar una franja traducida en la pantalla de empleados: «Estás viendo datos guardados», cuando enLinea() sea false. Haz además que desaparezca sola al volver la conexión, recargando el recurso con reload().
Ejercicio 10 (propuesto): Recordar el idioma elegido
Al pulsar un enlace de idioma, guarda la elección en una cookie idioma con Path=/. Modifica servidor.mjs para que, en la redirección de /, la cookie tenga prioridad sobre Accept-Language. Comprueba que un navegador en francés que eligió English entra en /en/.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Instalación i18n | ng add @angular/localize | Añade $localize como polyfill, tipos y dependencia |
| Idiomas | i18n.sourceLocale, i18n.locales | En angular.json. Declara siempre el idioma original |
| Marcar textos | i18n="significado\|descripción@@id" | Frases completas, con identificador propio |
| Marcar atributos | i18n-placeholder, i18n-title | Incluye aria-label y alt |
| Textos en código | $localize`:@@id:texto` | Solo literales. Los datos no se traducen |
| Plural | {n, plural, =0 {..} one {..} other {..}} | Las categorías dependen del idioma |
| Selección | {v, select, a {..} other {..}} | Las claves son valores, no textos |
| Extracción | ng extract-i18n | Compila y regenera solo messages.xlf |
| Textos que faltan | i18nMissingTranslation | error en producción. No detecta unidades sin <target> |
| Compilación | "localize": true | Una carpeta completa por idioma |
| Rutas | subPath | Carpeta y base href a la vez |
| Idioma actual | LOCALE_ID | Lo configura la compilación; no decide la moneda |
| Traducción en ejecución | loadTranslations | Una vez antes de arrancar. Para cambio instantáneo, una biblioteca |
| PWA | ng add @angular/pwa | Service worker, manifest e iconos |
| Registro | provideServiceWorker | Solo en producción y en contexto seguro |
| Ficheros | assetGroups, installMode | prefetch para arrancar sin red; lazy para el resto |
| Datos | dataGroups, strategy | performance primero caché, freshness primero red |
| Depuración | ngsw/state, caches.keys() | Muestra la versión, las pestañas y las cachés |
| Saltar la caché | ngsw-bypass | Como parámetro con APIs de otro origen |
| Versiones | SwUpdate, VERSION_READY | Avisa, activateUpdate y recarga |
| Estado roto | unrecoverable | Avisa y recarga |
| Emergencia | safety-worker.js | Publícalo como ngsw-worker.js para retirar el worker |
| Push | SwPush | Requiere servidor y claves VAPID |
| Idiomas y PWA | Ámbito por carpeta | Un service worker independiente por idioma |