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 con Intl y 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

  1. Buscar libros por título, autor o tema y ver portada, autores, año y número de ediciones.
  2. Ordenar los resultados por relevancia, título (con tildes y ñ correctas), antigüedad o ediciones.
  3. Ver el detalle de un libro: portada grande, descripción y temas.
  4. Marcar favoritos que se conservan al recargar y muestran cuándo se añadieron (“añadido hace 3 días”).
  5. 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/ y servicios/ 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: base debe coincidir con el nombre del repositorio. Si no, en GitHub Pages el HTML pedirá /assets/index.js en lugar de /buscador-libros/assets/index.js y 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: normalizarLibro es 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 AbortController compartido. 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). El AbortError resultante 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 rama main en 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 variables VITE_.
  • 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 con Intl.
  • Calidad y tests: lint, format:check y test:run en verde; cobertura alta en logica/ y servicios/.
  • Despliegue y documentación: el workflow pasa, la URL funciona en móvil y escritorio, y el README.md del 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.