El taller lee de PostgreSQL, y la ficha enseña lo que en un .md no cabía

Es la primera área que deja `src/content/**`. Se eligió esta porque es
donde estaban los datos que no cabían en el frontmatter de un archivo, y
porque es la que menos rutas toca si algo sale mal.

La ficha de género gana lo que pedía el artefacto y no estaba:

- Países con su relación: la cuna se distingue del país que lo adoptó, y
  va primero aunque alfabéticamente no le toque.
- Instrumentación con el peso de cada instrumento y su papel. Sin el
  peso, el tres cubano y una trompeta ocasional pesarían igual.
- Letristas con país, años y papel, de la tabla `persona`, la misma que
  firma las canciones del catálogo.
- Obras de referencia fechadas, acreditadas y con su cita, con el aviso
  de por qué se puede reproducir un fragmento.
- Linaje en las dos direcciones, distinguiendo derivar de fusionar. No
  enlaza a un género sin capítulo: el bolero-son existe en la base
  porque el bolero y el son confluyen en él, pero nadie ha escrito su
  ficha, y enlazarlo mandaba a un 404.
- El aviso de lo que no ha revisado nadie. Mira la ficha entera y no
  solo la fila del género: en los ocho que ya existían la prosa la
  escribió el titular y lo que vino de un modelo son los datos de
  alrededor, así que mirando solo el género el aviso callaba justo donde
  hacía falta.

Todo va debajo de la prosa y a todo lo ancho, no en una barra lateral.
Lo único al lado del texto es el índice del propio capítulo, que es lo
que cubre el ancho que la prosa no puede cubrir sola.

El sitemap deja de listar los capítulos desde los archivos: si no, un
género nuevo en la base no aparecería. Y `$lib/content` pierde los
lectores del taller —los archivos siguen ahí, son de donde salió el
volcado— para que nadie edite un .md que ya no pinta nada en pantalla.

Ocho pruebas nuevas, una por cada bloque que antes no existía.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent 8272f3c195
commit 8e57950820

@ -65,7 +65,7 @@ test.describe('Taller de letras', () => {
await page.goto('/taller/bolero'); await page.goto('/taller/bolero');
const datos = (await page.locator('.datos').boundingBox())!; const datos = (await page.locator('.datos').boundingBox())!;
const texto = (await page.locator('.texto').boundingBox())!; const texto = (await page.locator('.lectura .texto').boundingBox())!;
expect(datos.width).toBeGreaterThan(texto.width * 1.4); expect(datos.width).toBeGreaterThan(texto.width * 1.4);
expect(Math.abs(datos.x - texto.x)).toBeLessThan(2); expect(Math.abs(datos.x - texto.x)).toBeLessThan(2);
@ -122,10 +122,82 @@ test.describe('Taller de letras', () => {
}); });
test('un principio no enseña ficha de creación', async ({ page }) => { test('un principio no enseña ficha de creación', async ({ page }) => {
// La ficha es de los estilos; en un principio sería un panel vacío. // La ficha es de los estilos; en un principio serían bloques vacíos.
await page.goto('/taller/el-estribillo'); await page.goto('/taller/el-estribillo');
await expect(page.getByRole('heading', { level: 2, name: 'De un vistazo' })).toHaveCount(0); await expect(page.locator('.datos')).toHaveCount(0);
await expect(page.locator('.instrumentos')).toHaveCount(0);
await expect(page.getByRole('heading', { level: 2, name: 'Linaje' })).toHaveCount(0);
});
/*
* Lo que sigue solo existe desde que el taller lee de la base: en un archivo
* de Markdown no cabía la instrumentación con el peso de cada instrumento,
* ni los letristas con sus fechas, ni de qué géneros viene este.
*/
test('la ficha dice qué país es la cuna y cuáles lo adoptaron', async ({ page }) => {
await page.goto('/taller/bolero');
const paises = page.locator('.paises li');
await expect(paises).toHaveCount(6);
// La cuna va primero aunque alfabéticamente no le toque.
await expect(paises.first()).toContainText('Cuba');
await expect(paises.first()).toContainText('cuna');
await expect(paises.filter({ hasText: 'México' })).not.toContainText('cuna');
});
test('la instrumentación distingue lo esencial de lo ocasional', async ({ page }) => {
await page.goto('/taller/bolero');
const filas = page.locator('.instrumentos li');
await expect(filas.filter({ hasText: 'Guitarra' })).toContainText('esencial');
await expect(filas.filter({ hasText: 'Piano' })).toContainText('ocasional');
// El papel es lo que hace útil el dato: no basta con enumerar instrumentos.
await expect(filas.filter({ hasText: 'Requinto' })).toContainText('adornos');
});
test('los letristas traen país, años y papel', async ({ page }) => {
await page.goto('/taller/tango');
const gardel = page.locator('.personas li').filter({ hasText: 'Carlos Gardel' });
await expect(gardel).toContainText('Argentina · 1890–1935');
// «interprete» se guarda sin tilde, pero se lee con ella.
await expect(gardel).toContainText('intérprete');
});
test('las obras de referencia se fechan y se acreditan', async ({ page }) => {
await page.goto('/taller/bolero');
const primera = page.locator('.obras li').first();
await expect(primera).toContainText('1883');
await expect(primera).toContainText('Tristezas');
await expect(primera).toContainText('José «Pepe» Sánchez');
// Un fragmento de letra ajena solo se sostiene si se dice por qué está.
await expect(page.locator('.obras')).toHaveCount(1);
await expect(page.getByText('derecho de cita')).toBeVisible();
});
test('el linaje no enlaza a un género sin capítulo', async ({ page }) => {
/*
* El bolero-son existe en la base porque el bolero y el son confluyen en
* él, pero nadie ha escrito su ficha. Enlazarlo mandaba a un 404.
*/
await page.goto('/taller/bolero');
const linaje = page.locator('.rama');
await expect(linaje.getByRole('link', { name: 'Bachata' })).toBeVisible();
await expect(linaje.getByText('Bolero-son')).toBeVisible();
await expect(linaje.getByRole('link', { name: 'Bolero-son' })).toHaveCount(0);
});
test('se avisa de lo que aún no ha revisado nadie', async ({ page }) => {
// La musicología la reunió un modelo y la ficha se publica con el nombre
// del titular. Callarlo sería lo cómodo.
await page.goto('/taller/bolero');
await expect(page.locator('.sin-revisar')).toContainText('pendientes de revisión');
}); });
test('el taller está en la navegación principal', async ({ page }) => { test('el taller está en la navegación principal', async ({ page }) => {

@ -12,6 +12,7 @@
* no se hace; y las dos marcas exigen su color exacto, que no se respeta si * no se hace; y las dos marcas exigen su color exacto, que no se respeta si
* se tiñe un icono genérico. * se tiñe un icono genérico.
*/ */
import { resolve } from '$app/paths';
import type { Proveedor } from '$lib/types'; import type { Proveedor } from '$lib/types';
let { let {
@ -22,15 +23,24 @@
const NOMBRES: Record<Proveedor, string> = { google: 'Google', facebook: 'Facebook' }; const NOMBRES: Record<Proveedor, string> = { google: 'Google', facebook: 'Facebook' };
const enlace = (proveedor: Proveedor) => /*
`/acceso/${proveedor}?volverA=${encodeURIComponent(volverA)}`; * Por `resolve` y no escrita a mano: si la ruta cambia de nombre, esto deja
* de compilar en vez de llevar a un 404 solo al pulsar. Va en el propio
* atributo porque la regla de lint solo sabe seguir `resolve` cuando lo ve
* ahí; metido en una función, no puede comprobar nada.
*/
const volver = $derived(encodeURIComponent(volverA));
</script> </script>
{#if proveedores.length > 0} {#if proveedores.length > 0}
<ul class="proveedores"> <ul class="proveedores">
{#each proveedores as proveedor (proveedor)} {#each proveedores as proveedor (proveedor)}
<li> <li>
<a class="btn btn--ghost proveedor" href={enlace(proveedor)} data-proveedor={proveedor}> <a
class="btn btn--ghost proveedor"
href="{resolve('/acceso/[proveedor]', { proveedor })}?volverA={volver}"
data-proveedor={proveedor}
>
{#if proveedor === 'google'} {#if proveedor === 'google'}
<!-- La G de cuatro colores, tal cual la exige su guía de marca. --> <!-- La G de cuatro colores, tal cual la exige su guía de marca. -->
<svg viewBox="0 0 18 18" width="18" height="18" aria-hidden="true"> <svg viewBox="0 0 18 18" width="18" height="18" aria-hidden="true">

@ -11,11 +11,21 @@
* quitarle a quien lee justo lo que necesita para orientarse. * quitarle a quien lee justo lo que necesita para orientarse.
*/ */
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { CapituloDeTaller } from '$lib/types';
/*
* Pide lo que usa y nada más: slug y título. Antes pedía `CapituloDeTaller`
* entero —el tipo de cuando el taller vivía en archivos—, así que el día que
* el capítulo cambió de forma este componente dejó de compilar sin tener
* ningún motivo.
*/
interface Entrada {
slug: string;
titulo: string;
}
interface Props { interface Props {
principios: CapituloDeTaller[]; principios: Entrada[];
fichas: CapituloDeTaller[]; fichas: Entrada[];
/** Slug del capítulo abierto, si lo hay. */ /** Slug del capítulo abierto, si lo hay. */
actual?: string; actual?: string;
} }

@ -24,7 +24,6 @@ import type {
Album, Album,
AlbumConCanciones, AlbumConCanciones,
Cancion, Cancion,
CapituloDeTaller,
EntradaDeBlog, EntradaDeBlog,
Estilo, Estilo,
Etiqueta, Etiqueta,
@ -38,7 +37,6 @@ import { encabezadosDe, type Encabezado } from './anclas';
import { import {
parsearAlbum, parsearAlbum,
parsearCancion, parsearCancion,
parsearCapitulo,
parsearEntrada, parsearEntrada,
parsearLegal, parsearLegal,
parsearEstilo, parsearEstilo,
@ -60,14 +58,7 @@ const modCanciones = import.meta.glob<ModuloMarkdown>('/src/content/canciones/*.
eager: true eager: true
}); });
const modPaginas = import.meta.glob<ModuloMarkdown>('/src/content/paginas/*.md', { eager: true }); const modPaginas = import.meta.glob<ModuloMarkdown>('/src/content/paginas/*.md', { eager: true });
const modTaller = import.meta.glob<ModuloMarkdown>('/src/content/taller/*.md', { eager: true });
/* Los mismos capítulos sin compilar, para sacar sus encabezados. */
const crudoTaller = import.meta.glob('/src/content/taller/*.md', {
eager: true,
query: '?raw',
import: 'default'
}) as Record<string, string>;
const modBlog = import.meta.glob<ModuloMarkdown>('/src/content/blog/*.md', { eager: true }); const modBlog = import.meta.glob<ModuloMarkdown>('/src/content/blog/*.md', { eager: true });
const modLegal = import.meta.glob<ModuloMarkdown>('/src/content/legal/*.md', { eager: true }); const modLegal = import.meta.glob<ModuloMarkdown>('/src/content/legal/*.md', { eager: true });
@ -168,52 +159,18 @@ export function vecinasDeEntrada(slug: string): {
} }
/** Entradas que hablan de un tema concreto. */ /** Entradas que hablan de un tema concreto. */
/** Capitulos del taller, en el orden en que se leen. */ /*
export const capitulos: CapituloDeTaller[] = mapear(modTaller, parsearCapitulo).sort( * El taller ya no se lee de aquí.
(a, b) => a.orden - b.orden || a.titulo.localeCompare(b.titulo)
);
/** Los principios: el curso propiamente dicho. */
export function principios(): CapituloDeTaller[] {
return capitulos.filter((c) => c.tipo === 'principio');
}
/** Las fichas de creacion, una por estilo. */
export function fichasDeEstilo(): CapituloDeTaller[] {
return capitulos.filter((c) => c.tipo === 'ficha');
}
/**
* Apartados de un capítulo, para el índice que acompaña al texto.
* *
* Sale del Markdown en bruto, igual que el de los textos legales, y con la * Vive en PostgreSQL desde que la ficha de género necesitó países, instrumentos,
* misma función: así las anclas que calcula esto y las que pone el plugin al * letristas, obras y linaje: relaciones que en el frontmatter de un Markdown no
* compilar no pueden separarse. * caben. Lo sirve `$lib/server/taller`.
*/ *
export function apartadosDeCapitulo(slug: string): Encabezado[] { * Los archivos de `src/content/taller` siguen ahí a propósito: son de dónde salió
const entrada = Object.entries(crudoTaller).find(([ruta]) => slugDesdeRuta(ruta) === slug); * el volcado y con lo que se reconstruye la base. Lo que se ha quitado son los
return entrada ? encabezadosDe(entrada[1]) : []; * lectores, para que nadie los use por costumbre y edite un archivo que ya no
} * pinta nada en pantalla.
export function obtenerCapitulo(slug: string): CapituloDeTaller | undefined {
return capitulos.find((c) => c.slug === slug);
}
/**
* El capitulo anterior y el siguiente DENTRO DE SU BLOQUE: pasar de los
* principios a las fichas de estilo es cambiar de cosa, no seguir leyendo.
*/ */
export function vecinosDeCapitulo(slug: string): {
anterior?: CapituloDeTaller;
siguiente?: CapituloDeTaller;
} {
const capitulo = obtenerCapitulo(slug);
if (!capitulo) return {};
const bloque = capitulos.filter((c) => c.tipo === capitulo.tipo);
const i = bloque.findIndex((c) => c.slug === slug);
return { anterior: bloque[i - 1], siguiente: bloque[i + 1] };
}
/* ------------------------------------------------------------------------- /* -------------------------------------------------------------------------
* Album sintetico de singles * Album sintetico de singles
@ -509,11 +466,6 @@ export function cuerpoEstilo(slug: string): Component | undefined {
return cuerpo(modEstilos, slug); return cuerpo(modEstilos, slug);
} }
/** Componente con el texto de un capitulo del taller. */
export function cuerpoCapitulo(slug: string): Component | undefined {
return cuerpo(modTaller, slug);
}
/** Componente con el texto de una pagina libre. */ /** Componente con el texto de una pagina libre. */
export function cuerpoPagina(slug: string): Component | undefined { export function cuerpoPagina(slug: string): Component | undefined {
return cuerpo(modPaginas, slug); return cuerpo(modPaginas, slug);

@ -0,0 +1,74 @@
/**
* Compone el Markdown que ahora vive en la base de datos.
*
* Hasta la migración esto lo hacía mdsvex al construir: cada archivo se
* compilaba en un componente de Svelte. Con el texto en una columna hay que
* componerlo en cada petición, y por eso hay caché.
*
* Se comprobó antes de decidirlo: ningún archivo de contenido usaba sintaxis de
* Svelte, así que no se pierde nada al dejar de compilarlo. Lo único que había
* que reponer son las dos cosas que sí añadía el proyecto —los saltos de línea
* de los versos y las anclas de los encabezados—, y están abajo.
*/
import { marked } from 'marked';
import { ancla } from '$lib/content/anclas';
/**
* Caché por contenido, no por identificador.
*
* La clave es el propio texto: si el texto no cambió, el HTML tampoco, y si
* cambió la entrada vieja deja de usarse sola. Con una clave por capítulo
* habría que acordarse de invalidarla al editar, que es de lo que nadie se
* acuerda.
*
* El tope existe porque esto vive en un proceso que no se reinicia: sin él, un
* rastreador que pida mil páginas deja mil entradas en memoria para siempre.
*/
const CACHE = new Map<string, string>();
const TOPE = 500;
marked.use({
// Un salto de línea es un salto de línea. En una letra, el corte del verso
// es contenido: es lo mismo que hacía `remark-breaks` antes.
breaks: true,
gfm: true,
renderer: {
/*
* Anclas en los encabezados, con la misma función que usa el índice. Las
* dos tienen que dar la misma cadena o el índice enlazaría a sitios que no
* existen, y eso no da error: simplemente no lleva a ninguna parte.
*/
heading({ tokens, depth }) {
const texto = this.parser.parseInline(tokens);
const plano = tokens.map((t) => ('text' in t ? t.text : '')).join('');
return depth === 2
? `<h2 id="${ancla(plano)}">${texto}</h2>\n`
: `<h${depth}>${texto}</h${depth}>\n`;
}
}
});
/**
* Markdown a HTML.
*
* **No se sanea**, y es una decisión con fecha de caducidad: hoy el único autor
* es el titular y el contenido entra por scripts que él controla. En cuanto
* exista la administración —o alguien más pueda escribir— hay que meter un
* saneador aquí, en un solo sitio, antes de que haya nada que saneáramos tarde.
*/
export function componer(markdown: string | null | undefined): string {
if (!markdown) return '';
const guardado = CACHE.get(markdown);
if (guardado !== undefined) return guardado;
const html = marked.parse(markdown, { async: false });
// Se vacía entera en vez de ir echando la más vieja: no hace falta llevar la
// cuenta de accesos para algo que se vuelve a llenar en cuatro peticiones.
if (CACHE.size >= TOPE) CACHE.clear();
CACHE.set(markdown, html);
return html;
}

@ -0,0 +1,366 @@
/**
* El taller, leído de la base de datos.
*
* Es la primera área que deja de leer `src/content/**`. Se eligió esta y no el
* catálogo por dos razones: es donde están los datos nuevos —instrumentos,
* letristas, obras de referencia, linaje— que en archivos no cabían, y es la
* que menos rutas toca si algo sale mal.
*/
import { and, asc, eq, or, sql } from 'drizzle-orm';
import { db } from './db';
import {
capitulo,
genero,
generoAlias,
generoInstrumento,
generoOrigen,
generoPais,
generoPersona,
generoSeccion,
generoTema,
etiqueta,
instrumento,
obraReferencia,
obraReferenciaPersona,
pais,
persona
} from './db/schema';
import { componer } from './markdown';
import { encabezadosDe, type Encabezado } from '$lib/content/anclas';
/**
* Una entrada del índice del taller.
*
* Las fichas llevan además dos datos del género —de dónde viene y en qué
* compás—, porque la tarjeta del índice los enseña. Vienen en el mismo viaje,
* con un `left join`: una consulta por tarjeta serían veinte para pintar una
* lista.
*/
export interface EnElIndice {
slug: string;
titulo: string;
resumen: string;
orden: number;
tipo: 'principio' | 'ficha';
/** Solo en las fichas; en los principios, nulo. */
origenLugar: string | null;
compasNota: string | null;
}
/** El índice de la sección, que vive en el layout. */
export async function indiceDelTaller(): Promise<{
principios: EnElIndice[];
fichas: EnElIndice[];
}> {
const filas = await db
.select({
slug: capitulo.slug,
titulo: capitulo.titulo,
resumen: capitulo.resumen,
orden: capitulo.orden,
tipo: capitulo.tipo,
origenLugar: genero.origenLugar,
compasNota: genero.compasNota
})
.from(capitulo)
.leftJoin(genero, eq(genero.id, capitulo.generoId))
.where(eq(capitulo.estado, 'publicado'))
.orderBy(asc(capitulo.orden), asc(capitulo.titulo));
return {
principios: filas.filter((f) => f.tipo === 'principio'),
fichas: filas.filter((f) => f.tipo === 'ficha')
};
}
/** Lo que hace falta para pintar la ficha de un género. */
export interface FichaDeGenero {
nombre: string;
resumen: string;
origenLugar: string | null;
origenAno: number | null;
tempo: string | null;
rima: string | null;
metricaNota: string | null;
compasNota: string | null;
rasgos: string[];
/** De qué se habla en el género. Son etiquetas del catálogo, no texto suelto. */
temas: { slug: string; nombre: string }[];
sinRevisar: boolean;
paises: { codigo: string; nombre: string; gentilicio: string | null; relacion: string }[];
alias: { nombre: string; pais: string | null }[];
instrumentos: {
slug: string;
nombre: string;
familia: string;
importancia: string;
papel: string | null;
afinacion: string | null;
}[];
personas: {
slug: string;
nombre: string;
papel: string;
nacimiento: number | null;
defuncion: number | null;
pais: string | null;
}[];
obras: {
titulo: string;
ano: number | null;
nota: string | null;
cita: string | null;
autores: string[];
}[];
/** De qué viene y qué viene de él, en un solo viaje. */
linaje: {
direccion: 'de' | 'hacia';
slug: string;
nombre: string;
tipo: string;
nota: string | null;
/**
* Nulo si ese género no tiene capítulo publicado, y entonces se nombra sin
* enlazar. El bolero-son existe en la base porque el bolero y el son
* confluyen en él, pero nadie ha escrito su ficha: enlazarlo era mandar a
* quien lee a un 404.
*/
capitulo: string | null;
}[];
secciones: { clave: string; titulo: string; html: string }[];
}
/** Un capítulo con todo lo suyo. */
export interface CapituloCompleto {
slug: string;
titulo: string;
tipo: 'principio' | 'ficha';
resumen: string;
html: string;
apartados: Encabezado[];
ficha: FichaDeGenero | null;
anterior: EnElIndice | null;
siguiente: EnElIndice | null;
}
export async function obtenerCapitulo(slug: string): Promise<CapituloCompleto | null> {
const [fila] = await db
.select()
.from(capitulo)
.where(and(eq(capitulo.slug, slug), eq(capitulo.estado, 'publicado')))
.limit(1);
if (!fila) return null;
const { principios, fichas } = await indiceDelTaller();
const hermanos = fila.tipo === 'principio' ? principios : fichas;
const i = hermanos.findIndex((h) => h.slug === slug);
return {
slug: fila.slug,
titulo: fila.titulo,
tipo: fila.tipo,
resumen: fila.resumen,
html: componer(fila.cuerpo),
/*
* Los apartados salen del Markdown en bruto y no del HTML compuesto: es la
* misma función que usa el plugin de anclas, así que el índice y los
* destinos no pueden separarse.
*/
apartados: encabezadosDe(fila.cuerpo),
ficha: fila.generoId ? await fichaDeGenero(fila.generoId) : null,
anterior: i > 0 ? hermanos[i - 1] : null,
siguiente: i >= 0 && i < hermanos.length - 1 ? hermanos[i + 1] : null
};
}
async function fichaDeGenero(generoId: string): Promise<FichaDeGenero | null> {
const [g] = await db.select().from(genero).where(eq(genero.id, generoId)).limit(1);
if (!g) return null;
/*
* Todo en paralelo. Son ocho consultas pequeñas y ninguna depende de otra;
* encadenarlas sumaría ocho idas y vueltas a la base para pintar una página.
*/
const [paises, alias, instrumentos, personas, obras, deQuien, haciaQuien, secciones, temas] =
await Promise.all([
db
.select({
codigo: pais.codigo,
nombre: pais.nombre,
gentilicio: pais.gentilicio,
relacion: generoPais.relacion
})
.from(generoPais)
.innerJoin(pais, eq(pais.codigo, generoPais.paisCodigo))
.where(eq(generoPais.generoId, generoId))
/*
* La cuna primero: es el dato que se lee, los demás acompañan. Ordenar
* por el texto de la relación ponía «adoptado» delante de «cuna», que
* es exactamente al revés.
*/
.orderBy(sql`case ${generoPais.relacion} when 'cuna' then 0 else 1 end`, asc(pais.nombre)),
db
.select({ nombre: generoAlias.nombre, pais: generoAlias.paisCodigo })
.from(generoAlias)
.where(eq(generoAlias.generoId, generoId))
.orderBy(asc(generoAlias.nombre)),
db
.select({
slug: instrumento.slug,
nombre: instrumento.nombre,
familia: instrumento.familia,
afinacion: instrumento.afinacion,
importancia: generoInstrumento.importancia,
papel: generoInstrumento.papel
})
.from(generoInstrumento)
.innerJoin(instrumento, eq(instrumento.id, generoInstrumento.instrumentoId))
.where(eq(generoInstrumento.generoId, generoId))
.orderBy(asc(generoInstrumento.orden)),
db
.select({
slug: persona.slug,
nombre: persona.nombre,
papel: generoPersona.papel,
nacimiento: persona.nacimiento,
defuncion: persona.defuncion,
pais: pais.nombre
})
.from(generoPersona)
.innerJoin(persona, eq(persona.id, generoPersona.personaId))
// A la izquierda porque de una persona puede no saberse el país.
.leftJoin(pais, eq(pais.codigo, persona.paisCodigo))
.where(eq(generoPersona.generoId, generoId))
.orderBy(asc(generoPersona.orden)),
db
.select({
id: obraReferencia.id,
titulo: obraReferencia.titulo,
ano: obraReferencia.ano,
nota: obraReferencia.nota,
cita: obraReferencia.cita,
origen: obraReferencia.origen,
revisadoEn: obraReferencia.revisadoEn
})
.from(obraReferencia)
.where(eq(obraReferencia.generoId, generoId))
.orderBy(asc(obraReferencia.ano)),
db
.select({
slug: genero.slug,
nombre: genero.nombre,
tipo: generoOrigen.tipo,
nota: generoOrigen.nota,
capitulo: capitulo.slug
})
.from(generoOrigen)
.innerJoin(genero, eq(genero.id, generoOrigen.origenId))
.leftJoin(capitulo, and(eq(capitulo.generoId, genero.id), eq(capitulo.estado, 'publicado')))
.where(eq(generoOrigen.generoId, generoId))
.orderBy(asc(generoOrigen.orden)),
db
.select({
slug: genero.slug,
nombre: genero.nombre,
tipo: generoOrigen.tipo,
nota: generoOrigen.nota,
capitulo: capitulo.slug
})
.from(generoOrigen)
.innerJoin(genero, eq(genero.id, generoOrigen.generoId))
.leftJoin(capitulo, and(eq(capitulo.generoId, genero.id), eq(capitulo.estado, 'publicado')))
.where(eq(generoOrigen.origenId, generoId))
.orderBy(asc(genero.nombre)),
db
.select({
clave: generoSeccion.clave,
titulo: generoSeccion.titulo,
cuerpo: generoSeccion.cuerpo
})
.from(generoSeccion)
.where(eq(generoSeccion.generoId, generoId))
.orderBy(asc(generoSeccion.orden)),
/*
* La temática sale de `etiqueta`, la misma tabla que etiqueta las
* canciones. Es lo que permite —más adelante— ir del tema del género a
* los temas del catálogo que lo cumplen, cosa que con un array de texto
* no se podía.
*/
db
.select({ slug: etiqueta.slug, nombre: etiqueta.nombre })
.from(generoTema)
.innerJoin(etiqueta, eq(etiqueta.id, generoTema.etiquetaId))
.where(eq(generoTema.generoId, generoId))
.orderBy(asc(generoTema.orden))
]);
/* Los autores de cada obra, en una sola consulta para todas. */
const autoresPorObra = new Map<string, string[]>();
if (obras.length > 0) {
const filas = await db
.select({ obraId: obraReferenciaPersona.obraId, nombre: persona.nombre })
.from(obraReferenciaPersona)
.innerJoin(persona, eq(persona.id, obraReferenciaPersona.personaId))
.where(or(...obras.map((o) => eq(obraReferenciaPersona.obraId, o.id))))
.orderBy(asc(obraReferenciaPersona.orden));
for (const f of filas) {
autoresPorObra.set(f.obraId, [...(autoresPorObra.get(f.obraId) ?? []), f.nombre]);
}
}
return {
nombre: g.nombre,
resumen: g.resumen,
origenLugar: g.origenLugar,
origenAno: g.origenAno,
tempo: g.tempo,
rima: g.rima,
metricaNota: g.metricaNota,
compasNota: g.compasNota,
rasgos: g.rasgos ?? [],
temas,
/*
* Que se sepa lo que está sin verificar.
*
* No basta con mirar la fila del género: en los ocho que ya existían, la
* prosa la escribió el titular —`origen = 'manual'`— y lo que vino de un
* modelo son los datos de alrededor: la instrumentación, los letristas,
* las obras y el linaje. Mirando solo el género, el aviso callaba justo
* en las fichas donde hacía falta.
*/
sinRevisar:
(g.origen === 'importado' && g.revisadoEn === null) ||
obras.some((o) => o.origen === 'importado' && o.revisadoEn === null),
paises,
alias,
instrumentos,
personas,
obras: obras.map((o) => ({
titulo: o.titulo,
ano: o.ano,
nota: o.nota,
cita: o.cita,
autores: autoresPorObra.get(o.id) ?? []
})),
linaje: [
...deQuien.map((x) => ({ direccion: 'de' as const, ...x })),
...haciaQuien.map((x) => ({ direccion: 'hacia' as const, ...x }))
],
secciones: secciones.map((s) => ({
clave: s.clave,
titulo: s.titulo,
html: componer(s.cuerpo)
}))
};
}

@ -1,6 +1,5 @@
import { import {
canciones, canciones,
capitulos,
entradas, entradas,
estilos, estilos,
etiquetas, etiquetas,
@ -9,6 +8,7 @@ import {
slugsDeAlbum slugsDeAlbum
} from '$lib/content'; } from '$lib/content';
import { site } from '$lib/site'; import { site } from '$lib/site';
import { indiceDelTaller } from '$lib/server/taller';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
/** /**
@ -37,7 +37,15 @@ function url(ruta: string, prioridad: string, fecha?: string): string {
</url>`; </url>`;
} }
export const GET: RequestHandler = () => { export const GET: RequestHandler = async () => {
/*
* El taller ya no sale de los archivos sino de la base, así que este
* controlador es asíncrono. Lo demás sigue en `src/content` hasta que le
* toque: un mapa a medias no es peor que uno viejo, pero uno que se olvide
* de los géneros nuevos sí.
*/
const { principios, fichas } = await indiceDelTaller();
const rutas = [ const rutas = [
url('/', PRIORIDAD.portada), url('/', PRIORIDAD.portada),
url('/musica', PRIORIDAD.seccion), url('/musica', PRIORIDAD.seccion),
@ -51,7 +59,7 @@ export const GET: RequestHandler = () => {
// Incluye los álbumes de singles, que son sintéticos pero tienen página. // Incluye los álbumes de singles, que son sintéticos pero tienen página.
...slugsDeAlbum().map((slug) => url(`/albumes/${slug}`, PRIORIDAD.obra)), ...slugsDeAlbum().map((slug) => url(`/albumes/${slug}`, PRIORIDAD.obra)),
...canciones.map((c) => url(`/canciones/${c.slug}`, PRIORIDAD.obra)), ...canciones.map((c) => url(`/canciones/${c.slug}`, PRIORIDAD.obra)),
...capitulos.map((c) => url(`/taller/${c.slug}`, PRIORIDAD.obra)), ...[...principios, ...fichas].map((c) => url(`/taller/${c.slug}`, PRIORIDAD.obra)),
...entradas.map((e) => url(`/blog/${e.slug}`, PRIORIDAD.obra, e.fecha)), ...entradas.map((e) => url(`/blog/${e.slug}`, PRIORIDAD.obra, e.fecha)),
...etiquetas().map((e) => url(`/etiquetas/${e.slug}`, PRIORIDAD.resto)), ...etiquetas().map((e) => url(`/etiquetas/${e.slug}`, PRIORIDAD.resto)),
...paginas.map((p) => url(`/${p.slug}`, PRIORIDAD.resto)), ...paginas.map((p) => url(`/${p.slug}`, PRIORIDAD.resto)),

@ -1,9 +1,9 @@
import { fichasDeEstilo, principios } from '$lib/content'; import { indiceDelTaller } from '$lib/server/taller';
/** /**
* El índice del taller vive en el layout, así que se carga una vez y lo tienen * El índice del taller vive en el layout, así que se carga una vez y lo tienen
* todas las páginas de la sección: la portada y cada capítulo. * todas las páginas de la sección: la portada y cada capítulo.
*/ */
export function load() { export async function load() {
return { principios: principios(), fichas: fichasDeEstilo() }; return await indiceDelTaller();
} }

@ -60,12 +60,12 @@
{capitulo.titulo} {capitulo.titulo}
</a> </a>
</span> </span>
{#if capitulo.ficha.origen} {#if capitulo.origenLugar}
<span class="origen muted">{capitulo.ficha.origen}</span> <span class="origen muted">{capitulo.origenLugar}</span>
{/if} {/if}
<span class="resumen muted">{capitulo.resumen}</span> <span class="resumen muted">{capitulo.resumen}</span>
{#if capitulo.ficha.compas} {#if capitulo.compasNota}
<span class="compas">{capitulo.ficha.compas.split(';')[0]}</span> <span class="compas">{capitulo.compasNota.split(';')[0]}</span>
{/if} {/if}
</div> </div>
</li> </li>

@ -1,5 +0,0 @@
import { fichasDeEstilo, principios } from '$lib/content';
export function load() {
return { principios: principios(), fichas: fichasDeEstilo() };
}

@ -0,0 +1,14 @@
import { error } from '@sveltejs/kit';
import { obtenerCapitulo } from '$lib/server/taller';
/**
* Pasa a ser `+page.server.ts` y no `+page.ts` porque consulta la base: el
* cargador universal corre también en el navegador, y allí no hay base a la que
* preguntar.
*/
export async function load({ params }) {
const capitulo = await obtenerCapitulo(params.slug);
if (!capitulo) error(404, 'Ese capítulo no existe.');
return { capitulo };
}

@ -2,15 +2,16 @@
/** /**
* Un capítulo del taller: un principio del curso o la ficha de un género. * Un capítulo del taller: un principio del curso o la ficha de un género.
* *
* La ficha ya no lleva barra lateral. Los datos del género —compás, metro, * Es la primera página del sitio que lee de PostgreSQL y no de
* tempo, rima— van arriba y a todo lo ancho, en una franja comparable de un * `src/content/**`, y por eso enseña lo que en un archivo no cabía: los
* vistazo, y la prosa debajo con su medida de lectura. Antes eran una lista * países con su relación —cuna o adopción—, la instrumentación con el peso
* de definiciones apretada en una columna estrecha a la derecha: los datos * de cada instrumento, los letristas, las obras de referencia y el linaje.
* quedaban en letra pequeña y el texto, escorado.
* *
* Cuando el género tenga sus propias tablas —instrumentos, letristas, obras * La ficha no lleva barra lateral de datos: los cuatro comparables van
* de referencia, linaje—, cada uno entra como bloque a lo ancho debajo de la * arriba en una franja a todo lo ancho, la prosa debajo con su medida de
* prosa, en el mismo sitio donde hoy están la temática y los rasgos. * lectura y su índice al lado, y lo demás en bloques anchos. Un texto no
* puede llenar novecientos píxeles; lo que cubre ese ancho es lo que va a su
* lado y lo que va debajo.
*/ */
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte'; import Meta from '$lib/components/Meta.svelte';
@ -20,25 +21,90 @@
const capitulo = $derived(data.capitulo); const capitulo = $derived(data.capitulo);
const ficha = $derived(capitulo.ficha); const ficha = $derived(capitulo.ficha);
const Cuerpo = $derived(data.Cuerpo);
const esFicha = $derived(capitulo.tipo === 'ficha'); const esFicha = $derived(capitulo.tipo === 'ficha');
/*
* Cada lista sale del script y no del template. Con `{#if ficha}` alrededor
* de veinte bloques, cualquier cambio dentro obliga a razonar otra vez si el
* compilador sigue sabiendo que no es nula; así el template solo pregunta si
* hay algo que pintar.
*/
const paises = $derived(ficha?.paises ?? []);
const alias = $derived(ficha?.alias ?? []);
const secciones = $derived(ficha?.secciones ?? []);
const instrumentos = $derived(ficha?.instrumentos ?? []);
const personas = $derived(ficha?.personas ?? []);
const obras = $derived(ficha?.obras ?? []);
const rasgos = $derived(ficha?.rasgos ?? []);
const temas = $derived(ficha?.temas ?? []);
const vieneDe = $derived(ficha?.linaje.filter((l) => l.direccion === 'de') ?? []);
const vieneDeEl = $derived(ficha?.linaje.filter((l) => l.direccion === 'hacia') ?? []);
/** /**
* La franja de datos: solo lo corto y comparable. * La franja: solo lo corto y comparable.
* *
* `estructura` se queda fuera a propósito —es una frase entera, «A · B · * La estructura de las canciones se queda fuera —es una frase entera y en
* interludio · A», y en una celda de dato queda apretada—; va debajo, con su * una celda queda apretada—; entra como sección, con su sitio.
* sitio.
*/ */
const franja = $derived( const franja = $derived(
[ (
['Compás', ficha.compas], [
['Metro', ficha.metrica], ['Compás', ficha?.compasNota],
['Tempo', ficha.tempo], ['Metro', ficha?.metricaNota],
['Rima', ficha.rima] ['Tempo', ficha?.tempo],
].filter((fila): fila is [string, string] => Boolean(fila[1])) ['Rima', ficha?.rima]
] as [string, string | null | undefined][]
).filter((fila): fila is [string, string] => Boolean(fila[1]))
); );
/**
* El año de origen, solo si no está ya en el lugar.
*
* Muchos lugares vienen fechados de los archivos —«Santiago de Cuba, 1883»—
* y la semilla puso además el año en su columna, que es donde sirve para
* ordenar. Sin esta comprobación salía «Santiago de Cuba, 1883, 1883».
*/
const anoAparte = $derived(
ficha?.origenAno && !ficha.origenLugar?.includes(String(ficha.origenAno))
? ficha.origenAno
: null
);
/**
* Los papeles se guardan como código y se leen como palabra. En la base son
* un vocabulario cerrado —por eso «interprete» va sin tilde—; el acento es
* cosa de la pantalla.
*/
const PAPEL: Record<string, string> = {
fundador: 'fundador',
letrista: 'letrista',
compositor: 'compositor',
interprete: 'intérprete'
};
/**
* «Argentina · 1901–1951», «Cuba», «n. 1957», o nada si no se sabe nada.
*
* Se arma aquí y no en la plantilla porque Svelte recorta los espacios en los
* bordes de un bloque: el separador escrito como ` · ` dentro de un `{#if}`
* salía pegado a las dos palabras.
*/
function procedencia(p: {
pais: string | null;
nacimiento: number | null;
defuncion: number | null;
}): string {
const vida =
p.nacimiento && p.defuncion
? `${p.nacimiento}–${p.defuncion}`
: p.nacimiento
? `n. ${p.nacimiento}`
: p.defuncion
? `m. ${p.defuncion}`
: '';
return [p.pais, vida].filter(Boolean).join(' · ');
}
const migas = $derived([ const migas = $derived([
{ texto: 'Taller de letras', href: resolve('/taller') }, { texto: 'Taller de letras', href: resolve('/taller') },
{ texto: esFicha ? 'Fichas de creación' : 'Los principios' } { texto: esFicha ? 'Fichas de creación' : 'Los principios' }
@ -56,16 +122,37 @@
<h1>{capitulo.titulo}</h1> <h1>{capitulo.titulo}</h1>
{#if alias.length > 0}
<p class="alias">o {alias.map((a) => `«${a.nombre}»`).join(', ')}</p>
{/if}
{#if capitulo.resumen} {#if capitulo.resumen}
<p class="entradilla">{capitulo.resumen}</p> <p class="entradilla">{capitulo.resumen}</p>
{/if} {/if}
{#if esFicha && ficha.origen} {#if ficha?.origenLugar}
<p class="origen muted">{ficha.origen}</p> <p class="origen muted">
{ficha.origenLugar}{#if anoAparte}, {anoAparte}{/if}
</p>
{/if}
<!--
La cuna se distingue del país que lo adoptó. Es lo que pedía «los estilos
deberían estar clasificados por países»: no una etiqueta, una relación con
su matiz.
-->
{#if paises.length > 0}
<ul class="paises">
{#each paises as pais (pais.codigo)}
<li class:cuna={pais.relacion === 'cuna'}>
{pais.nombre}{#if pais.relacion === 'cuna'}<span class="relacion">cuna</span>{/if}
</li>
{/each}
</ul>
{/if} {/if}
</header> </header>
{#if esFicha && franja.length > 0} {#if franja.length > 0}
<div class="datos"> <div class="datos">
{#each franja as [etiqueta, valor] (etiqueta)} {#each franja as [etiqueta, valor] (etiqueta)}
<div class="dato"> <div class="dato">
@ -77,26 +164,28 @@
{/if} {/if}
<div class="lectura"> <div class="lectura">
<!--
El cuerpo llega compuesto del servidor. Es contenido propio y de un solo
autor; cuando exista la administración habrá que sanearlo, y el sitio para
hacerlo es `$lib/server/markdown`, en un punto y no en cada plantilla.
-->
<article class="texto"> <article class="texto">
{#if Cuerpo} <!-- eslint-disable-next-line svelte/no-at-html-tags -->
<Cuerpo /> {@html capitulo.html}
{/if}
</article> </article>
<!-- <!--
Los apartados del capítulo, al lado del texto. Los apartados del capítulo, al lado del texto. Cubren el ancho que la prosa
No es la lista de datos de antes —esa subió a la franja—: es el índice de lo no puede cubrir sola: para llenar novecientos píxeles con texto haría falta
que se está leyendo, el mismo recurso que llevan los textos legales. Y letra de veinticuatro puntos, y a menos de eso salen ciento veinte
cubre el ancho que la prosa no puede cubrir sola: para llenar novecientos caracteres por línea, que no hay quien siga.
píxeles con texto haría falta letra de veinticuatro puntos, y a menos de
eso salen ciento veinte caracteres por línea, que no hay quien siga.
--> -->
{#if data.apartados.length > 1} {#if capitulo.apartados.length > 1}
<aside class="apartados"> <aside class="apartados">
<nav aria-label="Apartados de este capítulo"> <nav aria-label="Apartados de este capítulo">
<p class="rotulo" id="lista-apartados">En este capítulo</p> <p class="rotulo" id="lista-apartados">En este capítulo</p>
<ul aria-labelledby="lista-apartados"> <ul aria-labelledby="lista-apartados">
{#each data.apartados as apartado (apartado.ancla)} {#each capitulo.apartados as apartado (apartado.ancla)}
<li><a href="#{apartado.ancla}">{apartado.texto}</a></li> <li><a href="#{apartado.ancla}">{apartado.texto}</a></li>
{/each} {/each}
</ul> </ul>
@ -105,52 +194,181 @@
{/if} {/if}
</div> </div>
{#if esFicha} {#each secciones as seccion (seccion.clave)}
{#if ficha.estructura} <section class="bloque">
<section class="bloque"> <h2>{seccion.titulo}</h2>
<h2>Estructura de las canciones</h2> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
<p class="estructura">{ficha.estructura}</p> <div class="texto">{@html seccion.html}</div>
</section> </section>
{/if} {/each}
{#if instrumentos.length > 0}
<section class="bloque">
<h2>Instrumentación</h2>
<ul class="instrumentos">
{#each instrumentos as i (i.slug)}
<li>
<span class="nombre">{i.nombre}</span>
<span class="peso {i.importancia}">{i.importancia}</span>
<span class="papel muted">
{i.papel ?? i.familia}
{#if i.afinacion}<span class="afinacion">Afinación: {i.afinacion}</span>{/if}
</span>
</li>
{/each}
</ul>
</section>
{/if}
{#if ficha.temas.length > 0} {#if personas.length > 0}
<section class="bloque"> <section class="bloque">
<h2>De qué se habla</h2> <h2>Quién lo escribió</h2>
<ul class="temas"> <ul class="personas">
{#each ficha.temas as tema (tema)} {#each personas as p (p.slug + p.papel)}
<li>{tema}</li> <li>
{/each} <span class="nombre">{p.nombre}</span>
</ul> <span class="vida muted">{procedencia(p)}</span>
</section> <span class="papel muted">{PAPEL[p.papel] ?? p.papel}</span>
{/if} </li>
{/each}
</ul>
</section>
{/if}
{#if ficha.rasgos.length > 0} {#if obras.length > 0}
<section class="bloque"> <section class="bloque">
<h2>Rasgos de escritura</h2> <h2>Obras de referencia</h2>
<ul class="rasgos"> <ul class="obras">
{#each ficha.rasgos as rasgo (rasgo)} {#each obras as obra (obra.titulo + obra.ano)}
<li>{rasgo}</li> <li>
{/each} <span class="ano">{obra.ano ?? '—'}</span>
</ul> <div>
</section> <p class="titulo">{obra.titulo}</p>
{/if} {#if obra.autores.length > 0}
<p class="autor muted">{obra.autores.join(' · ')}</p>
{/if}
{#if obra.cita}
<p class="cita">{obra.cita}</p>
{/if}
{#if obra.nota}
<p class="nota muted">{obra.nota}</p>
{/if}
</div>
</li>
{/each}
</ul>
<!--
Dos versos de una letra ajena son cita; la letra entera es otra cosa.
Decirlo aquí, donde están, es lo que hace que se cumpla.
-->
<p class="aviso muted">
Los fragmentos se reproducen al amparo del derecho de cita, para ilustrar el estilo. Las
letras completas son de sus autores y no se alojan aquí.
</p>
</section>
{/if}
{#if vieneDe.length > 0 || vieneDeEl.length > 0}
<section class="bloque">
<h2>Linaje</h2>
<div class="linaje">
{#if vieneDe.length > 0}
<div class="rama">
<p class="rotulo">De dónde viene</p>
<ul>
{#each vieneDe as rama (rama.slug + rama.tipo)}
<li class:fusiona={rama.tipo === 'fusiona'}>
{#if rama.capitulo}
<a href={resolve('/taller/[slug]', { slug: rama.capitulo })}>{rama.nombre}</a>
{:else}
<span class="nombre">{rama.nombre}</span>
{/if}
<span class="como">{rama.tipo}</span>
{#if rama.nota}<span class="porque muted">{rama.nota}</span>{/if}
</li>
{/each}
</ul>
</div>
{/if}
{#if vieneDeEl.length > 0}
<div class="rama">
<p class="rotulo">Qué viene de él</p>
<ul>
{#each vieneDeEl as rama (rama.slug + rama.tipo)}
<li class:fusiona={rama.tipo === 'fusiona'}>
{#if rama.capitulo}
<a href={resolve('/taller/[slug]', { slug: rama.capitulo })}>{rama.nombre}</a>
{:else}
<span class="nombre">{rama.nombre}</span>
{/if}
<span class="como">{rama.tipo}</span>
{#if rama.nota}<span class="porque muted">{rama.nota}</span>{/if}
</li>
{/each}
</ul>
</div>
{/if}
</div>
</section>
{/if}
<!--
La temática y los rasgos no comparten forma. La temática son etiquetas
disfrazadas de frase —«El amor no correspondido»— y se leen en rejilla; los
rasgos son frases de verdad y se leen en lista.
-->
{#if temas.length > 0}
<section class="bloque">
<h2>De qué se habla</h2>
<ul class="temas">
{#each temas as tema (tema.slug)}
<li>
<a href={resolve('/etiquetas/[etiqueta]', { etiqueta: tema.slug })}>{tema.nombre}</a>
</li>
{/each}
</ul>
</section>
{/if}
{#if rasgos.length > 0}
<section class="bloque">
<h2>Rasgos de escritura</h2>
<ul class="rasgos">
{#each rasgos as rasgo (rasgo)}
<li>{rasgo}</li>
{/each}
</ul>
</section>
{/if}
<!--
Que se sepa lo que está sin verificar. La ficha la escribió un modelo y se
publica con el nombre del titular; callarlo sería lo cómodo.
-->
{#if ficha?.sinRevisar}
<p class="sin-revisar">
Los datos de esta ficha —instrumentación, letristas, obras de referencia y linaje— los reunió un
modelo de lenguaje y <strong>están pendientes de revisión</strong>: las fechas y las
atribuciones no se han comprobado una a una.
</p>
{/if} {/if}
{#if data.anterior || data.siguiente} {#if capitulo.anterior || capitulo.siguiente}
<nav class="vecinos" aria-label="Otros capítulos"> <nav class="vecinos" aria-label="Otros capítulos">
{#if data.anterior} {#if capitulo.anterior}
<a class="vecino" href={resolve('/taller/[slug]', { slug: data.anterior.slug })}> <a class="vecino" href={resolve('/taller/[slug]', { slug: capitulo.anterior.slug })}>
<span class="muted">Anterior</span> <span class="muted">Anterior</span>
<span>{data.anterior.titulo}</span> <span>{capitulo.anterior.titulo}</span>
</a> </a>
{:else} {:else}
<span></span> <span></span>
{/if} {/if}
{#if data.siguiente} {#if capitulo.siguiente}
<a class="vecino derecha" href={resolve('/taller/[slug]', { slug: data.siguiente.slug })}> <a class="vecino derecha" href={resolve('/taller/[slug]', { slug: capitulo.siguiente.slug })}>
<span class="muted">Siguiente</span> <span class="muted">Siguiente</span>
<span>{data.siguiente.titulo}</span> <span>{capitulo.siguiente.titulo}</span>
</a> </a>
{/if} {/if}
</nav> </nav>
@ -173,6 +391,14 @@
font-size: var(--fs-3xl); font-size: var(--fs-3xl);
} }
.alias {
margin: var(--sp-1) 0 0;
font-family: var(--font-display);
font-style: italic;
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.entradilla { .entradilla {
font-size: var(--fs-lg); font-size: var(--fs-lg);
color: var(--c-text-muted); color: var(--c-text-muted);
@ -194,6 +420,38 @@
letter-spacing: 0.1em; letter-spacing: 0.1em;
} }
.paises {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
list-style: none;
margin: var(--sp-4) 0 0;
padding: 0;
}
.paises li {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-xs);
padding: 0.2rem 0.6rem;
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
color: var(--c-text-muted);
}
.paises li.cuna {
color: var(--c-accent);
border-color: color-mix(in oklab, var(--c-accent) 45%, transparent);
background: color-mix(in oklab, var(--c-accent) 10%, transparent);
}
.relacion {
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.75;
}
/* --- La franja de datos ---------------------------------------------- */ /* --- La franja de datos ---------------------------------------------- */
/* /*
@ -234,8 +492,7 @@
* Las dos columnas miden lo suyo —el texto su medida de lectura, el índice lo * Las dos columnas miden lo suyo —el texto su medida de lectura, el índice lo
* que necesita— y lo que sobra se va al corredor. Con anchos fijos quedaban * que necesita— y lo que sobra se va al corredor. Con anchos fijos quedaban
* ciento veinte píxeles muertos al final de la fila, que es exactamente el * ciento veinte píxeles muertos al final de la fila, que es exactamente el
* hueco que se ve como un fallo. Así el bloque llega hasta donde llega la * hueco que se ve como un fallo.
* franja de datos de arriba.
*/ */
.lectura { .lectura {
display: grid; display: grid;
@ -247,6 +504,7 @@
.texto { .texto {
min-width: 0; min-width: 0;
max-width: 62ch;
} }
.apartados { .apartados {
@ -324,17 +582,194 @@
border-bottom: 1px solid var(--c-border); border-bottom: 1px solid var(--c-border);
} }
.estructura { /* Las tres listas comparten caja: filas separadas por una línea de un píxel. */
font-family: var(--font-display); .instrumentos,
font-size: var(--fs-lg); .personas,
max-width: 62ch; .obras {
display: grid;
gap: 1px;
background: var(--c-border);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
overflow: hidden;
list-style: none;
margin: 0;
padding: 0;
}
.instrumentos li,
.personas li,
.obras li {
background: var(--c-bg-raised);
}
.instrumentos li {
display: grid;
grid-template-columns: 12rem 7rem minmax(0, 1fr);
gap: var(--sp-4);
align-items: center;
padding: var(--sp-3) var(--sp-4);
}
.instrumentos .nombre,
.personas .nombre {
font-weight: 500;
}
.papel {
font-size: var(--fs-sm);
}
.afinacion {
display: block;
font-size: var(--fs-xs);
font-family: var(--font-mono);
} }
/* /*
* La temática son etiquetas disfrazadas de frase —«El amor no * El peso no es adorno: sin él, el tres cubano y una trompeta ocasional
* correspondido»— y se leen en rejilla; los rasgos son frases de verdad y se * pesarían igual en la ficha, y no es verdad.
* leen en lista. Por eso no comparten forma.
*/ */
.peso {
justify-self: start;
font-size: var(--fs-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.15rem 0.5rem;
border-radius: var(--radius-boton);
white-space: nowrap;
color: var(--c-text-muted);
border: 1px solid var(--c-border);
}
.peso.esencial {
color: var(--c-accent);
border-color: color-mix(in oklab, var(--c-accent) 45%, transparent);
background: color-mix(in oklab, var(--c-accent) 10%, transparent);
}
.peso.ocasional {
border-style: dashed;
}
.personas li {
display: grid;
grid-template-columns: minmax(0, 1fr) 14rem 9rem;
gap: var(--sp-4);
align-items: baseline;
padding: var(--sp-3) var(--sp-4);
}
.personas .vida,
.personas .papel {
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.obras li {
display: grid;
grid-template-columns: 3.5rem minmax(0, 1fr);
gap: var(--sp-4);
align-items: baseline;
padding: var(--sp-4);
}
.obras .ano {
font-family: var(--font-mono);
font-size: var(--fs-sm);
color: var(--c-accent);
font-variant-numeric: tabular-nums;
}
.obras .titulo {
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-lg);
}
.obras .autor,
.obras .nota {
margin: var(--sp-1) 0 0;
font-size: var(--fs-sm);
}
.obras .cita {
margin: var(--sp-2) 0 0;
padding-left: var(--sp-3);
border-left: 2px solid var(--c-border);
font-family: var(--font-display);
font-style: italic;
white-space: pre-line;
}
.aviso {
margin-top: var(--sp-3);
font-size: var(--fs-xs);
max-width: 68ch;
}
/* --- Linaje -------------------------------------------------------------- */
.linaje {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: var(--sp-4);
align-items: start;
}
.rama {
padding: var(--sp-4);
background: var(--c-bg-raised);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
}
.rama ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-3);
}
.rama li {
display: grid;
gap: 0.1rem;
padding-left: var(--sp-3);
border-left: 2px solid var(--c-border);
}
/* Una fusión no es una derivación, y se ve. */
.rama li.fusiona {
border-left-color: var(--c-accent);
}
.rama a,
.rama .nombre {
font-family: var(--font-display);
font-size: var(--fs-lg);
text-decoration: none;
}
/* Sin ficha propia todavía: se nombra, pero no promete una página. */
.rama .nombre {
color: var(--c-text-muted);
}
.rama .como {
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
}
.rama .porque {
font-size: var(--fs-sm);
}
/* --- Temática y rasgos ---------------------------------------------------- */
.temas { .temas {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
@ -344,12 +779,19 @@
padding: 0; padding: 0;
} }
.temas li { .temas a {
display: block;
padding: var(--sp-3) var(--sp-4); padding: var(--sp-3) var(--sp-4);
background: var(--c-bg-raised); background: var(--c-bg-raised);
border: 1px solid var(--c-border); border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta); border-radius: var(--radius-tarjeta);
font-family: var(--font-display); font-family: var(--font-display);
text-decoration: none;
}
.temas a:hover {
border-color: color-mix(in oklab, var(--c-accent) 45%, transparent);
color: var(--c-accent);
} }
.rasgos { .rasgos {
@ -367,6 +809,15 @@
color: var(--c-text-muted); color: var(--c-text-muted);
} }
.sin-revisar {
margin-top: var(--sp-7);
padding: var(--sp-4);
border: 1px dashed var(--c-border);
border-radius: var(--radius-tarjeta);
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
/* --- Vecinos ----------------------------------------------------------- */ /* --- Vecinos ----------------------------------------------------------- */
.vecinos { .vecinos {
@ -392,4 +843,12 @@
.vecino.derecha { .vecino.derecha {
text-align: right; text-align: right;
} }
@media (max-width: 52rem) {
.instrumentos li,
.personas li {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-1);
}
}
</style> </style>

@ -1,19 +0,0 @@
import { error } from '@sveltejs/kit';
import {
apartadosDeCapitulo,
cuerpoCapitulo,
obtenerCapitulo,
vecinosDeCapitulo
} from '$lib/content';
export function load({ params }) {
const capitulo = obtenerCapitulo(params.slug);
if (!capitulo) error(404, 'Ese capítulo no existe.');
return {
capitulo,
...vecinosDeCapitulo(params.slug),
Cuerpo: cuerpoCapitulo(params.slug),
apartados: apartadosDeCapitulo(params.slug)
};
}
Loading…
Cancel
Save

Powered by TurnKey Linux.