diff --git a/e2e/sindicacion.e2e.ts b/e2e/sindicacion.e2e.ts index 3ad3422..9e67916 100644 --- a/e2e/sindicacion.e2e.ts +++ b/e2e/sindicacion.e2e.ts @@ -135,6 +135,52 @@ test.describe('Legal', () => { await lateral.getByRole('link', { name: 'Condiciones de venta' }).click(); await expect(page).toHaveURL('/legal/condiciones-de-venta'); }); + + /* + * El índice de apartados sale del Markdown en bruto y las anclas las pone un + * plugin al compilarlo: son dos caminos distintos que tienen que dar la misma + * cadena. Si se separan, los enlaces siguen pintándose y no llevan a ningún + * sitio, que es la clase de avería que no da error y no ve nadie. + */ + for (const slug of [ + 'aviso-legal', + 'condiciones-de-uso', + 'privacidad', + 'cookies', + 'condiciones-de-venta' + ]) { + test(`el índice de /legal/${slug} apunta a apartados que existen`, async ({ page }) => { + await page.goto(`/legal/${slug}`); + + const indice = page.getByRole('navigation', { name: 'Apartados de esta página' }); + const destinos = await indice + .getByRole('link') + .evaluateAll((enlaces) => enlaces.map((e) => e.getAttribute('href') ?? '')); + + expect(destinos.length).toBeGreaterThan(2); + for (const destino of destinos) { + await expect(page.locator(`h2${destino}`)).toHaveCount(1); + } + }); + } + + test('pinchar un apartado lo deja visible bajo la cabecera', async ({ page }) => { + // La cabecera es pegajosa: sin `scroll-margin-top` el salto deja el título + // justo debajo de ella y parece que no ha pasado nada. + await page.goto('/legal/condiciones-de-venta'); + + const indice = page.getByRole('navigation', { name: 'Apartados de esta página' }); + await indice.getByRole('link', { name: 'Derecho de desistimiento' }).click(); + + const apartado = page.locator('h2#derecho-de-desistimiento'); + await expect(apartado).toBeInViewport(); + + const debajo = await apartado.evaluate((h) => { + const cabecera = document.querySelector('header')!.getBoundingClientRect().bottom; + return h.getBoundingClientRect().top >= cabecera; + }); + expect(debajo).toBe(true); + }); }); test.describe('Sin terceros', () => { diff --git a/src/app.css b/src/app.css index 0656b36..eaa6076 100644 --- a/src/app.css +++ b/src/app.css @@ -138,6 +138,15 @@ */ --sal: cubic-bezier(0.16, 1, 0.3, 1); --suave: cubic-bezier(0.4, 0, 0.2, 1); + /* + * El ancho útil: 1184px. Se probó a subirlo a 88rem (1408px) para aprovechar + * los monitores anchos y salió al revés: la prosa tiene una medida de + * lectura y no crece con el contenedor, así que lo único que se ensanchó + * fue el hueco a su derecha. En la página legal se veía a la legua. + * + * Si algún día se ensancha, hay que dar antes a cada texto una columna que + * crezca con él; sin eso, más ancho es más vacío. + */ --container: 74rem; /* Alto de la barra del reproductor: lo usa el padding inferior del layout. */ diff --git a/src/lib/content/anclas.spec.ts b/src/lib/content/anclas.spec.ts new file mode 100644 index 0000000..364cb61 --- /dev/null +++ b/src/lib/content/anclas.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { ancla, encabezadosDe } from './anclas'; + +describe('ancla', () => { + it('quita las tildes y la eñe', () => { + expect(ancla('Política de privacidad')).toBe('politica-de-privacidad'); + expect(ancla('Años y años')).toBe('anos-y-anos'); + }); + + it('tira la puntuación en vez de dejarla en el ancla', () => { + expect(ancla('¿Qué se recoge, y para qué?')).toBe('que-se-recoge-y-para-que'); + }); + + it('no deja guiones sueltos en los extremos', () => { + expect(ancla(' ¡Pago! ')).toBe('pago'); + }); + + it('da algo con lo que enlazar aunque el título no tenga letras', () => { + expect(ancla('···')).toBe('seccion'); + }); + + it('lleva a la misma ancla el título escrito con tilde y sin ella', () => { + expect(ancla('Facturación')).toBe(ancla('Facturacion')); + }); +}); + +describe('encabezadosDe', () => { + it('recoge los apartados de nivel 2 en orden', () => { + const md = ['# Título', '', '## Primero', 'texto', '', '## Segundo', 'más texto'].join('\n'); + + expect(encabezadosDe(md)).toEqual([ + { texto: 'Primero', ancla: 'primero' }, + { texto: 'Segundo', ancla: 'segundo' } + ]); + }); + + it('no confunde el frontmatter con contenido', () => { + const md = ['---', 'titulo: Algo', 'nota: "## no es un apartado"', '---', '', '## Real'].join( + '\n' + ); + + expect(encabezadosDe(md)).toEqual([{ texto: 'Real', ancla: 'real' }]); + }); + + it('ignora las almohadillas dentro de un bloque de código', () => { + const md = ['## Uno', '', '```sh', '## esto es un comentario', '```', '', '## Dos'].join('\n'); + + expect(encabezadosDe(md).map((e) => e.texto)).toEqual(['Uno', 'Dos']); + }); + + it('deja el título limpio de énfasis y de enlaces', () => { + const md = '## El **derecho** de [desistimiento](/legal/condiciones-de-venta)'; + + expect(encabezadosDe(md)).toEqual([ + { texto: 'El derecho de desistimiento', ancla: 'el-derecho-de-desistimiento' } + ]); + }); + + it('deja fuera los niveles que no pinta el índice', () => { + const md = ['# Uno', '### Tres', '## Dos'].join('\n'); + + expect(encabezadosDe(md).map((e) => e.texto)).toEqual(['Dos']); + }); +}); diff --git a/src/lib/content/anclas.ts b/src/lib/content/anclas.ts new file mode 100644 index 0000000..d771082 --- /dev/null +++ b/src/lib/content/anclas.ts @@ -0,0 +1,73 @@ +/** + * Anclas para los encabezados de los textos largos. + * + * Se usa desde dos sitios que tienen que coincidir carácter a carácter: el + * plugin que pone el `id` al compilar el Markdown (en vite.config.ts) y el + * cargador que construye el índice de la página. Si las dos versiones se + * separaran, el índice enlazaría a anclas que no existen y no lo notaría + * nadie hasta pinchar. Por eso vive aquí, en un solo sitio. + */ + +/** Convierte el texto de un encabezado en un ancla estable. */ +export function ancla(texto: string): string { + return ( + texto + .toLowerCase() + // Separa las tildes de la letra para poder tirarlas: «política» y + // «politica» tienen que dar la misma ancla. + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/ñ/g, 'n') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'seccion' + ); +} + +/** Un encabezado de nivel 2, tal como lo pinta el índice lateral. */ +export type Encabezado = { texto: string; ancla: string }; + +/** + * Saca los encabezados de nivel 2 del Markdown en bruto. + * + * Se lee el texto original y no el HTML compilado porque en el momento de + * cargar la página el cuerpo ya es un componente de Svelte: dejó de ser texto + * y no hay nada que recorrer. + */ +export function encabezadosDe(markdown: string): Encabezado[] { + const encabezados: Encabezado[] = []; + let enBloqueDeCodigo = false; + let enFrontmatter = false; + + const lineas = markdown.split(/\r?\n/); + + for (const [i, linea] of lineas.entries()) { + // El frontmatter abre y cierra con `---`; dentro puede haber cualquier + // cosa, incluidas almohadillas. + if (linea.trim() === '---') { + if (i === 0) enFrontmatter = true; + else if (enFrontmatter) enFrontmatter = false; + continue; + } + if (enFrontmatter) continue; + + // Un `## ` dentro de un bloque de código es código, no un encabezado. + if (/^\s*(```|~~~)/.test(linea)) { + enBloqueDeCodigo = !enBloqueDeCodigo; + continue; + } + if (enBloqueDeCodigo) continue; + + const encontrado = /^##\s+(.+?)\s*#*\s*$/.exec(linea); + if (!encontrado) continue; + + // Quita el énfasis y los enlaces del título: en el índice sobran. + const texto = encontrado[1] + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/[*_`]/g, '') + .trim(); + + encabezados.push({ texto, ancla: ancla(texto) }); + } + + return encabezados; +} diff --git a/src/lib/content/index.ts b/src/lib/content/index.ts index c8c8646..2ccbb6b 100644 --- a/src/lib/content/index.ts +++ b/src/lib/content/index.ts @@ -34,6 +34,7 @@ import type { } from '$lib/types'; import { porFechaDesc } from '$lib/utils/date'; import { slugDesdeRuta } from './fields'; +import { encabezadosDe, type Encabezado } from './anclas'; import { parsearAlbum, parsearCancion, @@ -63,6 +64,18 @@ const modTaller = import.meta.glob('/src/content/taller/*.md', { const modBlog = import.meta.glob('/src/content/blog/*.md', { eager: true }); const modLegal = import.meta.glob('/src/content/legal/*.md', { eager: true }); +/* + * Los mismos textos legales, pero sin compilar. Se piden dos veces a proposito: + * de la version compilada sale el componente que se pinta, y de esta salen los + * encabezados del indice lateral. Cuando el cuerpo ya es un componente, el + * texto dejo de ser texto y no hay forma de recorrerlo. + */ +const crudoLegal = import.meta.glob('/src/content/legal/*.md', { + eager: true, + query: '?raw', + import: 'default' +}) as Record; + /** Aplica un parser al frontmatter de cada archivo del glob. */ function mapear( glob: Record, @@ -125,6 +138,12 @@ export function cuerpoLegal(slug: string): Component | undefined { return cuerpo(modLegal, slug); } +/** Apartados de una pagina legal, para el indice lateral. */ +export function indiceLegal(slug: string): Encabezado[] { + const entrada = Object.entries(crudoLegal).find(([ruta]) => slugDesdeRuta(ruta) === slug); + return entrada ? encabezadosDe(entrada[1]) : []; +} + export function obtenerEntrada(slug: string): EntradaDeBlog | undefined { return entradas.find((e) => e.slug === slug); } diff --git a/src/routes/bio/+page.svelte b/src/routes/bio/+page.svelte index ed59273..77b06d4 100644 --- a/src/routes/bio/+page.svelte +++ b/src/routes/bio/+page.svelte @@ -1,14 +1,19 @@ -
+

{data.pagina.titulo}

{#if data.pagina.descripcion} @@ -19,11 +24,74 @@ {/if}
+ + +
diff --git a/src/routes/bio/+page.ts b/src/routes/bio/+page.ts index 0fe4c72..b0fa64d 100644 --- a/src/routes/bio/+page.ts +++ b/src/routes/bio/+page.ts @@ -1,9 +1,28 @@ import { error } from '@sveltejs/kit'; -import { cuerpoPagina, obtenerPagina } from '$lib/content'; +import { albumes, canciones, cuerpoPagina, estilosConObra, obtenerPagina } from '$lib/content'; export function load() { const pagina = obtenerPagina('bio'); if (!pagina) error(404, 'Falta src/content/paginas/bio.md'); - return { pagina, Cuerpo: cuerpoPagina('bio') }; + /* + * Al lado del texto va lo que hay publicado. Sale del catálogo y no de un + * campo escrito a mano: una biografía que dice «catorce temas» y una web + * que enseña dieciocho se contradicen el día que se sube uno nuevo, y nadie + * se acuerda de volver aquí a corregirlo. + */ + const obra = estilosConObra() + .filter((e) => e.totalCanciones > 0) + .map((e) => ({ + slug: e.estilo.slug, + nombre: e.estilo.nombre, + cuantas: e.totalCanciones + })); + + return { + pagina, + Cuerpo: cuerpoPagina('bio'), + obra, + totales: { canciones: canciones.length, albumes: albumes.length, estilos: obra.length } + }; } diff --git a/src/routes/legal/+page.svelte b/src/routes/legal/+page.svelte index 13a1786..f1f9c55 100644 --- a/src/routes/legal/+page.svelte +++ b/src/routes/legal/+page.svelte @@ -1,6 +1,8 @@