Módulo 22: Proyecto final
Objetivos del módulo
- Diseñar y construir una aplicación web completa con JavaScript vanilla y Vite
- Organizar el código en módulos ES con responsabilidades claras: estado, servicios, lógica, interfaz y router
- Consumir una API REST real gestionando carga, errores, cancelación y tiempos de espera
- Persistir datos con
localStorage, formatear textos conIntly cuidar la accesibilidad - Probar la lógica de negocio con Vitest y mantener la calidad con ESLint y Prettier
- Publicar la aplicación en GitHub Pages con GitHub Actions
1. El proyecto: un buscador de libros
Vamos a construir “Buscador de libros”: una aplicación que busca en Open Library (el catálogo abierto de Internet Archive), muestra los detalles de cada libro y permite guardar favoritos.
¿Por qué un buscador y no un gestor Kanban? Porque obliga a usar más de lo aprendido: fetch contra una API REST real con errores, cancelación y estados de carga (módulos 12 y 17), un router con URLs compartibles (#/?q=quijote) y varias pantallas, y un uso natural de Intl (plurales, listas, fechas relativas y Collator). Además no necesita backend: Open Library es gratuita, no pide clave y admite CORS. El Kanban se centra en arrastrar y soltar y en el estado local; lo tienes como ampliación al final.
Requisitos funcionales
- Buscar libros por título, autor o tema y ver portada, autores, año y número de ediciones.
- Ordenar los resultados por relevancia, título (con tildes y ñ correctas), antigüedad o ediciones.
- Ver el detalle de un libro: portada grande, descripción y temas.
- Marcar favoritos que se conservan al recargar y muestran cuándo se añadieron (“añadido hace 3 días”).
- Cada búsqueda tiene su URL: se puede compartir y funcionan Atrás y Adelante.
Requisitos no funcionales: sin frameworks (módulos ES y Vite); mensajes claros mientras se carga, sin resultados y si falla la red (con “Reintentar”); accesible con teclado y lector de pantalla; lógica cubierta por tests; código sin errores de ESLint y formateado con Prettier; publicación automática en cada push a main.
2. Arquitectura
buscador-libros/
├── index.html · vite.config.js · eslint.config.js · .prettierrc.json · .github/workflows/deploy.yml
├── src/
│ ├── main.js · estado.js · router.js · estilos.css ← arranque, "store" y router por hash
│ ├── logica/ libros.js · formato.js ← funciones PURAS, sin DOM ni red
│ ├── servicios/ api.js · favoritos.js ← acceso al exterior: fetch y localStorage
│ └── ui/ dom.js · componentes.js · vistas.js ← todo lo que toca el DOM
└── test/ un *.test.js por módulo
flowchart TB
M["main.js"] --> R["router.js"]
M --> E["estado.js"]
M --> V
R -->|"rutas: /, /libro/:id, /favoritos"| V
subgraph UI["ui/"]
V["vistas.js"] --> C["componentes.js"] --> D["dom.js"]
end
V --> API["servicios/api.js"]
V --> FAV["servicios/favoritos.js"]
V --> E
API --> L["logica/libros.js"]
C --> F["logica/formato.js"]
API -->|fetch| OL[("Open Library")]
FAV --> LS[("localStorage")]
📘 Concepto: La regla de esta arquitectura es la separación de responsabilidades: cada módulo hace una cosa, y las dependencias van en una sola dirección (la interfaz usa servicios y lógica, nunca al revés). Así,
logica/yservicios/se prueban sin navegador, y podrías cambiar la interfaz por React sin tocarlos.
Crear el proyecto
npm create vite@latest buscador-libros -- --template vanilla
cd buscador-libros
npm install
npm install -D vitest @vitest/coverage-v8 happy-dom eslint @eslint/js globals prettier eslint-config-prettier
Borra los ficheros de ejemplo de src/ y añade a package.json los scripts del módulo 21: "test": "vitest", "test:run": "vitest run", "coverage": "vitest run --coverage", "lint": "eslint .", "format": "prettier --write ." y "format:check": "prettier --check ." (además de dev, build y preview, que ya trae Vite).
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
base: '/buscador-libros/', // la web vivirá en https://<usuario>.github.io/buscador-libros/
test: {
environment: 'happy-dom',
coverage: { provider: 'v8', include: ['src/logica/**', 'src/servicios/**', 'src/router.js'] },
},
});
⚠️ Importante:
basedebe coincidir con el nombre del repositorio. Si no, en GitHub Pages el HTML pedirá/assets/index.jsen lugar de/buscador-libros/assets/index.jsy verás una página en blanco con errores 404 en la consola.
3. El HTML
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Buscador de libros</title>
</head>
<body>
<a class="saltar" href="#app">Saltar al contenido</a>
<header class="cabecera">
<a class="logo" href="#/">📚 Buscador de libros</a>
<nav aria-label="Principal">
<a href="#/">Buscar</a>
<a href="#/favoritos">Favoritos (<span id="contador-favoritos">0</span>)</a>
</nav>
</header>
<main id="app" tabindex="-1"></main>
<footer>Datos de <a href="https://openlibrary.org">Open Library</a></footer>
<script type="module" src="/src/main.js"></script>
</body>
</html>
La estructura semántica y el enlace “Saltar al contenido” ayudan a quien navega con teclado o lector de pantalla. Todo lo que cambia se pinta dentro de <main id="app">.
4. La lógica de negocio (src/logica/)
Funciones puras: reciben datos y devuelven datos. Empezamos por aquí porque son el corazón de la aplicación y lo más fácil de testear.
// src/logica/libros.js
const URL_PORTADAS = 'https://covers.openlibrary.org/b/id';
const collator = new Intl.Collator('es-ES');
/** Convierte un resultado de la API de Open Library en nuestro modelo de libro */
export function normalizarLibro(doc) {
return {
id: doc.key.replace('/works/', ''), // '/works/OL503666W' → 'OL503666W'
titulo: doc.title ?? 'Sin título',
autores: doc.author_name ?? [],
anio: doc.first_publish_year ?? null,
ediciones: doc.edition_count ?? 0,
portada: doc.cover_i ? `${URL_PORTADAS}/${doc.cover_i}-M.jpg` : null,
};
}
const SIN_AÑO = Number.MAX_SAFE_INTEGER; // los libros sin año van al final
export const ORDENES = {
relevancia: { etiqueta: 'Relevancia', comparar: null }, // el orden que devuelve la API
titulo: { etiqueta: 'Título (A-Z)', comparar: (a, b) => collator.compare(a.titulo, b.titulo) },
antiguos: { etiqueta: 'Más antiguos', comparar: (a, b) => (a.anio ?? SIN_AÑO) - (b.anio ?? SIN_AÑO) },
ediciones: { etiqueta: 'Más ediciones', comparar: (a, b) => b.ediciones - a.ediciones },
};
/** Devuelve una copia ordenada (no modifica el original) */
export function ordenarLibros(libros, orden = 'relevancia') {
const comparar = ORDENES[orden]?.comparar;
return comparar ? libros.toSorted(comparar) : [...libros];
}
💡 Consejo:
normalizarLibroes una capa anticorrupción: los nombres de la API (author_name,cover_i) solo aparecen aquí. Si Open Library cambia su formato, solo tocas esta función.
// src/logica/formato.js — textos que dependen del idioma; los formateadores se crean UNA vez
const LOCALE = 'es-ES';
const numero = new Intl.NumberFormat(LOCALE);
const plural = new Intl.PluralRules(LOCALE);
const lista = new Intl.ListFormat(LOCALE);
const relativo = new Intl.RelativeTimeFormat(LOCALE, { numeric: 'auto' });
/** '1 edición', '2 ediciones', '12.345 ediciones' */
export function contar(n, singular, pluralTexto) {
return `${numero.format(n)} ${plural.select(n) === 'one' ? singular : pluralTexto}`;
}
export const textoLista = (elementos) => lista.format(elementos);
/** 'Ana', 'Ana y Luis', 'Ana, Luis, Eva y 2 más' */
export function textoAutores(autores) {
if (autores.length === 0) return 'Autor desconocido';
if (autores.length <= 3) return lista.format(autores);
return lista.format([...autores.slice(0, 3), `${autores.length - 3} más`]);
}
const UNIDADES = [['year', 31_536_000], ['month', 2_592_000], ['week', 604_800],
['day', 86_400], ['hour', 3_600], ['minute', 60]];
/** 'hace 3 días', 'ayer', 'hace 5 minutos', 'ahora mismo' */
export function haceCuanto(fechaISO, ahora = new Date()) {
const segundos = (new Date(fechaISO) - ahora) / 1000;
for (const [unidad, duracion] of UNIDADES) {
if (Math.abs(segundos) >= duracion) {
return relativo.format(Math.round(segundos / duracion), unidad);
}
}
return 'ahora mismo';
}
5. Los servicios (src/servicios/)
La API: fetch con errores, cancelación y tiempo máximo
// src/servicios/api.js — el único módulo que habla con Open Library
import { normalizarLibro } from '../logica/libros.js';
const BASE = import.meta.env.VITE_API_URL ?? 'https://openlibrary.org';
const CAMPOS = 'key,title,author_name,first_publish_year,edition_count,cover_i';
export class ErrorApi extends Error {
constructor(mensaje, { estado, cause } = {}) {
super(mensaje, { cause });
this.name = 'ErrorApi';
this.estado = estado;
}
}
async function pedirJSON(url, { signal, fetchFn = fetch, timeout = 10_000 } = {}) {
const limite = AbortSignal.timeout(timeout);
const señal = signal ? AbortSignal.any([signal, limite]) : limite;
let respuesta;
try {
respuesta = await fetchFn(url, { signal: señal });
} catch (error) {
if (error.name === 'AbortError') throw error; // la cancelamos nosotros: no es un fallo
const mensaje = error.name === 'TimeoutError'
? 'Open Library está tardando demasiado. Inténtalo de nuevo.'
: 'No se ha podido conectar. Revisa tu conexión a internet.';
throw new ErrorApi(mensaje, { cause: error });
}
if (!respuesta.ok) {
const mensaje = `El servidor ha respondido con un error (${respuesta.status}).`;
throw new ErrorApi(mensaje, { estado: respuesta.status });
}
return respuesta.json();
}
export async function buscarLibros(termino, opciones = {}) {
const q = termino.trim();
if (q === '') return { total: 0, libros: [] };
const params = new URLSearchParams({ q, fields: CAMPOS, limit: '24', lang: 'es' });
const datos = await pedirJSON(`${BASE}/search.json?${params}`, opciones);
return { total: datos.numFound, libros: datos.docs.map(normalizarLibro) };
}
export async function obtenerDetalle(id, opciones = {}) {
const datos = await pedirJSON(`${BASE}/works/${encodeURIComponent(id)}.json`, opciones);
const { description, covers, subjects = [] } = datos;
return {
id,
titulo: datos.title,
// La descripción puede venir como texto o como { type, value }
descripcion: typeof description === 'string' ? description : (description?.value ?? ''),
temas: subjects.slice(0, 6),
portada: covers?.[0] ? `https://covers.openlibrary.org/b/id/${covers[0]}-L.jpg` : null,
};
}
Puntos clave: fields pide solo los campos que usamos; AbortSignal.any() combina la cancelación que decida la vista con un tiempo máximo de 10 s; los errores técnicos se traducen a mensajes para personas conservando el original en cause (módulo 10); fetchFn se puede inyectar en los tests; y VITE_API_URL permite apuntar a otro servidor sin tocar el código.
Favoritos en localStorage
// src/servicios/favoritos.js
const CLAVE = 'buscador-libros:favoritos:v1'; // con versión, por si cambia el formato
export function crearFavoritos(almacen = localStorage) {
function leer() {
try {
return JSON.parse(almacen.getItem(CLAVE)) ?? [];
} catch {
return []; // datos corruptos o almacenamiento bloqueado
}
}
function guardar(lista) {
try {
almacen.setItem(CLAVE, JSON.stringify(lista));
} catch (error) {
console.warn('No se han podido guardar los favoritos', error); // p. ej., QuotaExceededError
}
}
return {
todos: leer,
contiene: (id) => leer().some((libro) => libro.id === id),
/** Añade o quita el libro. Devuelve true si ha quedado como favorito */
alternar(libro, ahora = new Date()) {
const lista = leer();
const existe = lista.some((f) => f.id === libro.id);
const nueva = existe
? lista.filter((f) => f.id !== libro.id)
: [...lista, { ...libro, añadido: ahora.toISOString() }];
guardar(nueva);
return !existe;
},
};
}
💡 Consejo: Para guardar miles de libros o usar las portadas sin conexión, sustituirías este módulo por otro basado en IndexedDB con
idb(módulo 18) con la misma interfaz (todos,contiene,alternar, aunque asíncrona). El resto de la aplicación apenas cambiaría.
6. Estado y router
// src/estado.js — un "store" mínimo: estado + suscriptores (patrón observador)
export function crearEstado(inicial) {
let estado = inicial;
const suscriptores = new Set();
return {
obtener: () => estado,
actualizar(cambios) {
estado = { ...estado, ...cambios }; // inmutable: siempre un objeto nuevo
suscriptores.forEach((fn) => fn(estado));
},
suscribir(fn) {
suscriptores.add(fn);
return () => suscriptores.delete(fn); // para darse de baja
},
};
}
El router usa el hash porque GitHub Pages no tiene fallback de SPA: con la History API, recargar en /buscador-libros/favoritos daría un 404 (módulo 18).
// src/router.js — rutas del tipo #/libro/:id?clave=valor
export function analizarHash(hash) {
const [ruta, consulta = ''] = hash.replace(/^#/, '').split('?');
return { ruta: ruta || '/', query: Object.fromEntries(new URLSearchParams(consulta)) };
}
export function resolverRuta(rutas, ruta) {
for (const [patron, vista] of Object.entries(rutas)) {
// '/libro/:id' → /^\/libro\/([^/]+)$/, y guardamos el nombre 'id'
const nombres = [];
const fuente = patron.replace(/:(\w+)/g, (_, nombre) => (nombres.push(nombre), '([^/]+)'));
const coincidencia = ruta.match(new RegExp(`^${fuente}$`));
if (coincidencia) {
const valores = nombres.map((nombre, i) => [nombre, decodeURIComponent(coincidencia[i + 1])]);
return { vista, params: Object.fromEntries(valores) };
}
}
return null;
}
export function iniciarRouter(rutas, vistaNoEncontrada) {
function navegar() {
// Los enlaces internos normales (#app, del "saltar al contenido") no son rutas
if (location.hash && !location.hash.startsWith('#/')) return;
const { ruta, query } = analizarHash(location.hash);
const encontrada = resolverRuta(rutas, ruta);
(encontrada?.vista ?? vistaNoEncontrada)({ params: encontrada?.params ?? {}, query });
}
window.addEventListener('hashchange', navegar);
navegar();
}
7. La interfaz (src/ui/)
Un helper para crear elementos de forma segura
Pintar datos de una API con innerHTML es peligroso: un título con <img onerror=...> ejecutaría código (XSS). Este helper crea los elementos con createElement y añade los textos como nodos de texto, que nunca se interpretan como HTML:
// src/ui/dom.js
const vacio = (valor) => valor === null || valor === undefined || valor === false;
export function el(etiqueta, atributos = {}, ...hijos) {
const nodo = document.createElement(etiqueta);
for (const [nombre, valor] of Object.entries(atributos)) {
if (nombre.startsWith('on')) {
nodo.addEventListener(nombre.slice(2), valor); // onclick → evento click
} else if (valor === true) {
nodo.setAttribute(nombre, ''); // atributos booleanos: required, selected...
} else if (!vacio(valor)) {
nodo.setAttribute(nombre, valor);
}
}
nodo.append(...hijos.flat().filter((hijo) => !vacio(hijo)));
return nodo;
}
/** Sustituye el contenido principal y mueve el foco al nuevo título (accesibilidad) */
export function pintar(...nodos) {
const app = document.querySelector('#app');
app.replaceChildren(...nodos.filter(Boolean));
app.querySelector('h1')?.focus();
}
el('a', { href: '#/' }, 'Inicio') equivale a <a href="#/">Inicio</a>, y como los hijos null o false se ignoran, puedes escribir condicion && el(...).
Componentes
// src/ui/componentes.js
import { el } from './dom.js';
import { contar, textoAutores } from '../logica/formato.js';
export function botonFavorito(libro, { esFavorito, alAlternar }) {
const boton = el('button', { type: 'button', class: 'favorito', 'aria-label': `Favorito: ${libro.titulo}` });
const actualizar = (activo) => {
boton.textContent = activo ? '★' : '☆';
boton.setAttribute('aria-pressed', String(activo)); // el lector de pantalla anuncia "pulsado"
};
boton.addEventListener('click', () => actualizar(alAlternar(libro)));
actualizar(esFavorito);
return boton;
}
export function tarjetaLibro(libro, { nota, ...opcionesFavorito }) {
const meta = [libro.anio, contar(libro.ediciones, 'edición', 'ediciones'), nota];
return el('li', { class: 'tarjeta' },
libro.portada
? el('img', { src: libro.portada, alt: '', loading: 'lazy', width: 90, height: 135 })
: el('div', { class: 'sin-portada', 'aria-hidden': 'true' }, '📖'),
el('div', { class: 'info' },
el('h2', {}, el('a', { href: `#/libro/${libro.id}` }, libro.titulo)),
el('p', {}, textoAutores(libro.autores)),
el('p', { class: 'meta' }, meta.filter(Boolean).join(' · '))),
botonFavorito(libro, opcionesFavorito));
}
export const estadoCarga = (texto) =>
el('p', { class: 'estado', role: 'status' }, el('span', { class: 'spinner', 'aria-hidden': 'true' }), texto);
export const estadoVacio = (texto) => el('p', { class: 'estado' }, texto);
export const estadoError = (mensaje, alReintentar) =>
el('div', { class: 'estado error', role: 'alert' }, el('p', {}, mensaje),
alReintentar && el('button', { type: 'button', onclick: alReintentar }, 'Reintentar'));
Las vistas
Cada pantalla es una función. crearVistas recibe sus dependencias (estado y favoritos) en lugar de importarlas: es inyección de dependencias, y es lo que permite probarlas con datos falsos.
// src/ui/vistas.js
import { el, pintar } from './dom.js';
import { tarjetaLibro, estadoCarga, estadoError, estadoVacio } from './componentes.js';
import { buscarLibros, obtenerDetalle } from '../servicios/api.js';
import { ORDENES, ordenarLibros } from '../logica/libros.js';
import { contar, textoLista, haceCuanto } from '../logica/formato.js';
import { analizarHash } from '../router.js';
export function crearVistas({ estado, favoritos }) {
let controlador = null;
// Cancela la petición de la pantalla anterior y crea una señal nueva
function nuevaSeñal() {
controlador?.abort();
controlador = new AbortController();
return controlador.signal;
}
function alternarFavorito(libro) {
const esFavorito = favoritos.alternar(libro);
estado.actualizar({ favoritos: favoritos.todos() }); // avisa a los suscriptores
return esFavorito;
}
const tarjeta = (libro, nota, alAlternar = alternarFavorito) =>
tarjetaLibro(libro, { nota, esFavorito: favoritos.contiene(libro.id), alAlternar });
function formularioBusqueda(termino, orden) {
const enviar = (evento) => {
evento.preventDefault();
const datos = new FormData(evento.currentTarget);
const hash = `#/?${new URLSearchParams({ q: datos.get('q').trim(), orden: datos.get('orden') })}`;
if (location.hash === hash) {
busqueda(analizarHash(hash)); // misma URL: no habrá evento hashchange
} else {
location.hash = hash; // el router pintará la vista y la búsqueda queda en el historial
}
};
const opciones = Object.entries(ORDENES).map(([valor, { etiqueta }]) =>
el('option', { value: valor, selected: valor === orden }, etiqueta),
);
return el('form', { role: 'search', class: 'buscador', onsubmit: enviar },
el('label', { for: 'q' }, 'Buscar libros'),
el('input', { id: 'q', name: 'q', type: 'search', value: termino, required: true }),
el('label', { for: 'orden' }, 'Ordenar por'),
el('select', { id: 'orden', name: 'orden', onchange: (e) => e.target.form.requestSubmit() }, opciones),
el('button', { type: 'submit' }, 'Buscar'));
}
async function busqueda({ query }) {
const signal = nuevaSeñal();
const termino = query.q ?? '';
const orden = Object.hasOwn(ORDENES, query.orden ?? '') ? query.orden : 'relevancia';
const zona = el('section', { 'aria-live': 'polite' });
document.title = 'Buscador de libros';
pintar(el('h1', { tabindex: '-1' }, 'Buscar libros'), formularioBusqueda(termino, orden), zona);
if (!termino) return zona.append(estadoVacio('Escribe un título, un autor o un tema.'));
zona.append(estadoCarga(`Buscando «${termino}»…`));
try {
const { total, libros } = await buscarLibros(termino, { signal });
if (libros.length === 0) {
return zona.replaceChildren(estadoVacio(`No hay resultados para «${termino}».`));
}
const resumen = `${contar(total, 'resultado', 'resultados')} para «${termino}»`;
zona.replaceChildren(
el('p', { class: 'resumen' }, resumen),
el('ul', { class: 'rejilla' }, ordenarLibros(libros, orden).map((l) => tarjeta(l))),
);
} catch (error) {
if (error.name === 'AbortError') return; // el usuario se ha ido a otra pantalla
zona.replaceChildren(estadoError(error.message, () => busqueda({ query })));
}
}
async function detalle({ params }) {
const signal = nuevaSeñal();
const contenedor = el('article', { class: 'detalle' }, estadoCarga('Cargando el libro…'));
pintar(el('button', { type: 'button', onclick: () => history.back() }, '← Volver'), contenedor);
try {
const libro = await obtenerDetalle(params.id, { signal });
document.title = `${libro.titulo} · Buscador de libros`;
contenedor.replaceChildren(
libro.portada && el('img', { src: libro.portada, alt: `Portada de ${libro.titulo}` }),
el('div', {},
el('h1', { tabindex: '-1' }, libro.titulo),
el('p', { class: 'descripcion' }, libro.descripcion || 'Sin descripción.'),
libro.temas.length > 0 && el('p', {}, `Temas: ${textoLista(libro.temas)}`)),
);
contenedor.querySelector('h1').focus();
} catch (error) {
if (error.name === 'AbortError') return;
const mensaje = error.estado === 404 ? 'Este libro no existe.' : error.message;
contenedor.replaceChildren(estadoError(mensaje, () => detalle({ params })));
}
}
function listaFavoritos() {
nuevaSeñal(); // cancela cualquier búsqueda pendiente
document.title = 'Favoritos · Buscador de libros';
const libros = favoritos.todos().toReversed(); // los más recientes primero
const alQuitar = (libro) => {
const resultado = alternarFavorito(libro);
listaFavoritos(); // repinta la lista sin el libro
return resultado;
};
const tarjetas = libros.map((l) => tarjeta(l, `añadido ${haceCuanto(l.añadido)}`, alQuitar));
pintar(
el('h1', { tabindex: '-1' }, 'Mis favoritos'),
libros.length > 0
? el('ul', { class: 'rejilla' }, tarjetas)
: estadoVacio('Todavía no tienes favoritos. Pulsa ☆ en cualquier libro para guardarlo.'),
);
}
function noEncontrada() {
nuevaSeñal();
pintar(el('h1', { tabindex: '-1' }, 'Página no encontrada'), el('a', { href: '#/' }, 'Volver al buscador'));
}
return { busqueda, detalle, favoritos: listaFavoritos, noEncontrada };
}
⚠️ Importante: Fíjate en el
AbortControllercompartido. Si el usuario busca “quijote” y, antes de que llegue la respuesta, entra en Favoritos, la búsqueda se cancela. Sin esto, una respuesta tardía podría pintar resultados encima de otra pantalla (una condición de carrera). ElAbortErrorresultante no es un fallo, así que se ignora.
El punto de entrada
// src/main.js
import './estilos.css';
import { crearEstado } from './estado.js';
import { crearFavoritos } from './servicios/favoritos.js';
import { crearVistas } from './ui/vistas.js';
import { iniciarRouter } from './router.js';
const favoritos = crearFavoritos();
const estado = crearEstado({ favoritos: favoritos.todos() });
const vistas = crearVistas({ estado, favoritos });
// El contador de la cabecera se actualiza solo cada vez que cambia el estado
const contador = document.querySelector('#contador-favoritos');
const pintarContador = (actual) => (contador.textContent = actual.favoritos.length);
estado.suscribir(pintarContador);
pintarContador(estado.obtener());
// Si se cambian los favoritos en otra pestaña, sincronizamos (evento storage, módulo 18)
window.addEventListener('storage', () => estado.actualizar({ favoritos: favoritos.todos() }));
iniciarRouter(
{ '/': vistas.busqueda, '/libro/:id': vistas.detalle, '/favoritos': vistas.favoritos },
vistas.noEncontrada,
);
Estilos y accesibilidad
El diseño de src/estilos.css es libre (rejilla con grid, tarjetas, tema oscuro con prefers-color-scheme…), pero debe incluir un :focus-visible bien visible, ocultar el enlace .saltar hasta que reciba el foco (position: absolute; left: -999px y left: 1rem en :focus), usar white-space: pre-line en la descripción y desactivar la animación del spinner con @media (prefers-reduced-motion: reduce).
| Técnica de accesibilidad aplicada | Dónde |
|---|---|
HTML semántico (header, nav, main, h1 → h2, listas, button, form role="search") y <label for> | Toda la app |
El foco va al <h1> al cambiar de pantalla, y document.title cambia | pintar() y vistas |
Regiones vivas: role="status" (cargando), role="alert" (error), aria-live (resultados) | Componentes de estado |
Botón de alternancia con aria-pressed y una etiqueta que no cambia | botonFavorito |
Imágenes decorativas con alt=""; portada del detalle con alt descriptivo | Tarjetas y detalle |
8. Tests de la lógica de negocio
La pirámide del módulo 21: muchos tests de la lógica pura, algunos de los servicios con fetch simulado y unos pocos de integración con el DOM.
// test/libros.test.js (extracto)
import { it, expect } from 'vitest';
import { ordenarLibros } from '../src/logica/libros.js';
const libros = [
{ titulo: 'Zalacaín el aventurero', anio: 1909 },
{ titulo: 'Ñaque', anio: null },
{ titulo: 'Árbol de la ciencia', anio: 1911 },
];
it('ordena por título respetando tildes y ñ, y deja al final los libros sin año', () => {
const titulos = ordenarLibros(libros, 'titulo').map((l) => l.titulo);
expect(titulos).toEqual(['Árbol de la ciencia', 'Ñaque', 'Zalacaín el aventurero']);
expect(ordenarLibros(libros, 'antiguos').map((l) => l.anio)).toEqual([1909, 1911, null]);
expect(libros[0].titulo).toBe('Zalacaín el aventurero'); // el original no cambia
});
// test/api.test.js (extracto)
import { describe, it, expect, vi } from 'vitest';
import { buscarLibros, ErrorApi } from '../src/servicios/api.js';
describe('buscarLibros', () => {
it('llama a la API con el término limpio y normaliza la respuesta', async () => {
const datos = { numFound: 1, docs: [{ key: '/works/OL1W', title: 'Niebla' }] };
const fetchFn = vi.fn().mockResolvedValue({ ok: true, json: async () => datos });
const resultado = await buscarLibros(' unamuno niebla ', { fetchFn });
const url = new URL(fetchFn.mock.calls[0][0]);
expect(url.searchParams.get('q')).toBe('unamuno niebla');
expect(resultado.libros[0]).toMatchObject({ id: 'OL1W', titulo: 'Niebla' });
});
it('no hace ninguna petición si el término está vacío', async () => {
const fetchFn = vi.fn();
expect(await buscarLibros(' ', { fetchFn })).toEqual({ total: 0, libros: [] });
expect(fetchFn).not.toHaveBeenCalled();
});
it('convierte un error HTTP en un ErrorApi con el código', async () => {
const promesa = buscarLibros('quijote', { fetchFn: vi.fn().mockResolvedValue({ ok: false, status: 503 }) });
await expect(promesa).rejects.toBeInstanceOf(ErrorApi);
await expect(promesa).rejects.toMatchObject({ estado: 503 });
});
it('da un mensaje comprensible si no hay conexión', async () => {
const fetchFn = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
await expect(buscarLibros('quijote', { fetchFn })).rejects.toThrow('No se ha podido conectar');
});
});
Para los tests de integración de las vistas, sustituye el módulo de red entero con vi.mock('../src/servicios/api.js', () => ({ buscarLibros: vi.fn(), obtenerDetalle: vi.fn() })), crea <main id="app"> en el document de happy-dom, llama a await vistas.busqueda({ query: { q: 'quijote' } }) y comprueba el DOM resultante: el texto 1 resultado para «quijote», que click() en la estrella cambie aria-pressed a 'true' y que estado.obtener().favoritos tenga un elemento.
Completa la batería con tests de formato.js (contar, textoAutores y haceCuanto con una fecha fija), favoritos.js (añadir, quitar, persistir entre instancias y datos corruptos), router.js y del resto de vistas (error con “Reintentar”, detalle y favoritos). La versión de referencia del proyecto tiene 27 tests en 6 ficheros.
9. Calidad: ESLint y Prettier
Usa la configuración del módulo 21 (js/recommended, globals.browser, globals.node para *.config.js y eslint-config-prettier/flat al final), un .prettierrc.json con { "singleQuote": true, "trailingComma": "all", "printWidth": 100 } y un .prettierignore con dist/, coverage/ y package-lock.json.
Ejecuta npm run format una vez (algunos fragmentos de este módulo están compactados para que quepan mejor en la página y Prettier los reformateará) y, antes de cada commit, comprueba que todo está limpio:
npm run lint && npm run format:check && npm run test:run && npm run build
10. Publicar en GitHub Pages con GitHub Actions
Paso 1. Crea en GitHub un repositorio llamado buscador-libros (el mismo nombre que en base) y sube el proyecto:
git init
git add .
git commit -m "Buscador de libros"
git branch -M main
git remote add origin https://github.com/<tu-usuario>/buscador-libros.git
git push -u origin main
Paso 2. En el repositorio, ve a Settings → Pages y en Source elige GitHub Actions.
Paso 3. Crea el workflow .github/workflows/deploy.yml:
name: Calidad y despliegue
on:
push:
branches: [main]
pull_request:
workflow_dispatch: # permite lanzarlo a mano desde la pestaña Actions
permissions:
contents: read
pages: write # necesario para publicar en GitHub Pages
id-token: write # necesario para verificar el despliegue
concurrency:
group: pages
cancel-in-progress: true
jobs:
calidad:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run format:check
- run: npm run test:run
- run: npm run build
- if: github.ref == 'refs/heads/main'
uses: actions/configure-pages@v6
- if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v5 # sube la carpeta dist como "artefacto" de Pages
with:
path: ./dist
desplegar:
needs: calidad # solo si todo lo anterior ha pasado
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.despliegue.outputs.page_url }}
steps:
- id: despliegue
uses: actions/deploy-pages@v5
Paso 4. Haz commit y git push. En la pestaña Actions verás primero el trabajo calidad (lint, formato, tests y build) y, si todo pasa, desplegar. Tu aplicación quedará publicada en https://<tu-usuario>.github.io/buscador-libros/.
💡 Consejo: Los pull requests también ejecutan
calidad(sin desplegar). Protege la ramamainen Settings → Branches para que no se pueda fusionar nada que rompa los tests.
| Problema | Causa probable | Solución |
|---|---|---|
Página en blanco y 404 de /assets/... | base no coincide con el repositorio | Corrige base en vite.config.js |
Falla npm ci | Falta package-lock.json en el repositorio | Haz npm install y commitea el lockfile |
| Error de permisos o imágenes rotas solo en Pages | Pages no usa GitHub Actions como fuente / rutas absolutas a mano (/img/x.png) | Settings → Pages → Source: GitHub Actions / importa los recursos o usa import.meta.env.BASE_URL |
11. Checklist final de calidad
- Funcionalidad: se cumplen los cinco requisitos; recargar en cualquier pantalla funciona.
- Errores: probado sin conexión (DevTools → Network → Offline), con red lenta y con un libro inexistente.
- Seguridad: ningún dato externo pasa por
innerHTML; no hay secretos en el código ni en variablesVITE_. - Accesibilidad: todo se usa solo con teclado; Lighthouse → Accessibility ≥ 95. Rendimiento e i18n: imágenes con
loading="lazy"y dimensiones (Lighthouse → Performance en verde); números, plurales, listas y fechas conIntl. - Calidad y tests:
lint,format:checkytest:runen verde; cobertura alta enlogica/yservicios/. - Despliegue y documentación: el workflow pasa, la URL funciona en móvil y escritorio, y el
README.mddel repositorio explica qué es, cómo instalarlo, cómo probarlo y dónde verlo.
12. Ejercicios: propuestas de ampliación
Ejercicio 1: Paginación o scroll infinito
La API admite el parámetro page. Añade un botón “Cargar más” (o un IntersectionObserver sobre un centinela, módulo 18) que pida la siguiente página y añada los resultados sin duplicados.
Ejercicio 2: IndexedDB, modo sin conexión y PWA
Guarda en IndexedDB (con idb) las búsquedas recientes y los detalles visitados para mostrarlos sin red, y convierte la aplicación en una PWA con vite-plugin-pwa (manifiesto, iconos y Service Worker).
Ejercicio 3: TypeScript y tests E2E
Define las interfaces Libro, DetalleLibro y Favorito, renombra los ficheros a .ts y añade npx tsc --noEmit a la CI. Después escribe con Playwright tres tests E2E: buscar y ver resultados, marcar un favorito y verlo tras recargar, y simular un fallo de red con page.route().
Ejercicio 4: Un backend propio en Node
Crea con Express o Fastify un proxy de Open Library con caché en memoria (un Map con caducidad de 10 minutos) y una ruta /api/favoritos que guarde en un fichero JSON. Apunta la aplicación a él con VITE_API_URL.
Ejercicio 5: Con un framework, o el Kanban
Reescribe la interfaz con React, Vue o Svelte reutilizando sin cambios logica/, servicios/ y sus tests; o aplica esta misma arquitectura a un gestor Kanban con arrastrar y soltar, fechas límite (“vence mañana”) e IndexedDB.
Resumen
| Pieza | Responsabilidad | Técnicas del curso |
|---|---|---|
logica/libros.js | Normalizar y ordenar | Funciones puras, toSorted, Intl.Collator |
logica/formato.js | Textos dependientes del idioma | NumberFormat, PluralRules, ListFormat, RelativeTimeFormat |
servicios/api.js | Hablar con Open Library | fetch, URLSearchParams, AbortSignal.any/timeout, errores con cause |
servicios/favoritos.js | Persistencia | localStorage, JSON, try/catch, claves versionadas |
estado.js | Estado compartido | Patrón observador, inmutabilidad, Set |
router.js | Navegación | Hash, hashchange, expresiones regulares |
ui/ | Interfaz segura, accesible y reutilizable | createElement, eventos, ARIA, AbortController, gestión del foco |
test/ | Confianza | Vitest, vi.fn, vi.mock, happy-dom |
| Calidad y despliegue | Automatización | ESLint, Prettier, Vite, GitHub Actions, GitHub Pages |
¡Enhorabuena! Has pasado de no saber qué era una variable a construir, probar y publicar una aplicación web profesional. El siguiente paso es tuyo: elige una ampliación, un framework o un backend, y sigue construyendo.