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');
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(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 }) => {
// 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 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 }) => {

@ -12,6 +12,7 @@
* no se hace; y las dos marcas exigen su color exacto, que no se respeta si
* se tiñe un icono genérico.
*/
import { resolve } from '$app/paths';
import type { Proveedor } from '$lib/types';
let {
@ -22,15 +23,24 @@
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>
{#if proveedores.length > 0}
<ul class="proveedores">
{#each proveedores as proveedor (proveedor)}
<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'}
<!-- 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">

@ -11,11 +11,21 @@
* quitarle a quien lee justo lo que necesita para orientarse.
*/
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 {
principios: CapituloDeTaller[];
fichas: CapituloDeTaller[];
principios: Entrada[];
fichas: Entrada[];
/** Slug del capítulo abierto, si lo hay. */
actual?: string;
}

@ -24,7 +24,6 @@ import type {
Album,
AlbumConCanciones,
Cancion,
CapituloDeTaller,
EntradaDeBlog,
Estilo,
Etiqueta,
@ -38,7 +37,6 @@ import { encabezadosDe, type Encabezado } from './anclas';
import {
parsearAlbum,
parsearCancion,
parsearCapitulo,
parsearEntrada,
parsearLegal,
parsearEstilo,
@ -60,14 +58,7 @@ const modCanciones = import.meta.glob<ModuloMarkdown>('/src/content/canciones/*.
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 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. */
/** Capitulos del taller, en el orden en que se leen. */
export const capitulos: CapituloDeTaller[] = mapear(modTaller, parsearCapitulo).sort(
(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.
/*
* El taller ya no se lee de aquí.
*
* Sale del Markdown en bruto, igual que el de los textos legales, y con la
* misma función: así las anclas que calcula esto y las que pone el plugin al
* compilar no pueden separarse.
*/
export function apartadosDeCapitulo(slug: string): Encabezado[] {
const entrada = Object.entries(crudoTaller).find(([ruta]) => slugDesdeRuta(ruta) === slug);
return entrada ? encabezadosDe(entrada[1]) : [];
}
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.
* Vive en PostgreSQL desde que la ficha de género necesitó países, instrumentos,
* letristas, obras y linaje: relaciones que en el frontmatter de un Markdown no
* caben. Lo sirve `$lib/server/taller`.
*
* Los archivos de `src/content/taller` siguen ahí a propósito: son de dónde salió
* el volcado y con lo que se reconstruye la base. Lo que se ha quitado son los
* lectores, para que nadie los use por costumbre y edite un archivo que ya no
* pinta nada en pantalla.
*/
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
@ -509,11 +466,6 @@ export function cuerpoEstilo(slug: string): Component | undefined {
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. */
export function cuerpoPagina(slug: string): Component | undefined {
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 {
canciones,
capitulos,
entradas,
estilos,
etiquetas,
@ -9,6 +8,7 @@ import {
slugsDeAlbum
} from '$lib/content';
import { site } from '$lib/site';
import { indiceDelTaller } from '$lib/server/taller';
import type { RequestHandler } from './$types';
/**
@ -37,7 +37,15 @@ function url(ruta: string, prioridad: string, fecha?: string): string {
</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 = [
url('/', PRIORIDAD.portada),
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.
...slugsDeAlbum().map((slug) => url(`/albumes/${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)),
...etiquetas().map((e) => url(`/etiquetas/${e.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
* todas las páginas de la sección: la portada y cada capítulo.
*/
export function load() {
return { principios: principios(), fichas: fichasDeEstilo() };
export async function load() {
return await indiceDelTaller();
}

@ -60,12 +60,12 @@
{capitulo.titulo}
</a>
</span>
{#if capitulo.ficha.origen}
<span class="origen muted">{capitulo.ficha.origen}</span>
{#if capitulo.origenLugar}
<span class="origen muted">{capitulo.origenLugar}</span>
{/if}
<span class="resumen muted">{capitulo.resumen}</span>
{#if capitulo.ficha.compas}
<span class="compas">{capitulo.ficha.compas.split(';')[0]}</span>
{#if capitulo.compasNota}
<span class="compas">{capitulo.compasNota.split(';')[0]}</span>
{/if}
</div>
</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.
*
* La ficha ya no lleva barra lateral. Los datos del género —compás, metro,
* tempo, rima— van arriba y a todo lo ancho, en una franja comparable de un
* vistazo, y la prosa debajo con su medida de lectura. Antes eran una lista
* de definiciones apretada en una columna estrecha a la derecha: los datos
* quedaban en letra pequeña y el texto, escorado.
* Es la primera página del sitio que lee de PostgreSQL y no de
* `src/content/**`, y por eso enseña lo que en un archivo no cabía: los
* países con su relación —cuna o adopción—, la instrumentación con el peso
* de cada instrumento, los letristas, las obras de referencia y el linaje.
*
* Cuando el género tenga sus propias tablas —instrumentos, letristas, obras
* de referencia, linaje—, cada uno entra como bloque a lo ancho debajo de la
* prosa, en el mismo sitio donde hoy están la temática y los rasgos.
* La ficha no lleva barra lateral de datos: los cuatro comparables van
* arriba en una franja a todo lo ancho, la prosa debajo con su medida de
* 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 Meta from '$lib/components/Meta.svelte';
@ -20,25 +21,90 @@
const capitulo = $derived(data.capitulo);
const ficha = $derived(capitulo.ficha);
const Cuerpo = $derived(data.Cuerpo);
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 ·
* interludio · A», y en una celda de dato queda apretada—; va debajo, con su
* sitio.
* La estructura de las canciones se queda fuera —es una frase entera y en
* una celda queda apretada—; entra como sección, con su sitio.
*/
const franja = $derived(
[
['Compás', ficha.compas],
['Metro', ficha.metrica],
['Tempo', ficha.tempo],
['Rima', ficha.rima]
].filter((fila): fila is [string, string] => Boolean(fila[1]))
(
[
['Compás', ficha?.compasNota],
['Metro', ficha?.metricaNota],
['Tempo', ficha?.tempo],
['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([
{ texto: 'Taller de letras', href: resolve('/taller') },
{ texto: esFicha ? 'Fichas de creación' : 'Los principios' }
@ -56,16 +122,37 @@
<h1>{capitulo.titulo}</h1>
{#if alias.length > 0}
<p class="alias">o {alias.map((a) => `«${a.nombre}»`).join(', ')}</p>
{/if}
{#if capitulo.resumen}
<p class="entradilla">{capitulo.resumen}</p>
{/if}
{#if esFicha && ficha.origen}
<p class="origen muted">{ficha.origen}</p>
{#if ficha?.origenLugar}
<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}
</header>
{#if esFicha && franja.length > 0}
{#if franja.length > 0}
<div class="datos">
{#each franja as [etiqueta, valor] (etiqueta)}
<div class="dato">
@ -77,26 +164,28 @@
{/if}
<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">
{#if Cuerpo}
<Cuerpo />
{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html capitulo.html}
</article>
<!--
Los apartados del capítulo, al lado del texto.
No es la lista de datos de antes —esa subió a la franja—: es el índice de lo
que se está leyendo, el mismo recurso que llevan los textos legales. Y
cubre el ancho que la prosa no puede cubrir sola: para llenar novecientos
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.
Los apartados del capítulo, al lado del texto. Cubren el ancho que la prosa
no puede cubrir sola: para llenar novecientos 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">
<nav aria-label="Apartados de este capítulo">
<p class="rotulo" id="lista-apartados">En este capítulo</p>
<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>
{/each}
</ul>
@ -105,52 +194,181 @@
{/if}
</div>
{#if esFicha}
{#if ficha.estructura}
<section class="bloque">
<h2>Estructura de las canciones</h2>
<p class="estructura">{ficha.estructura}</p>
</section>
{/if}
{#each secciones as seccion (seccion.clave)}
<section class="bloque">
<h2>{seccion.titulo}</h2>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="texto">{@html seccion.html}</div>
</section>
{/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}
<section class="bloque">
<h2>De qué se habla</h2>
<ul class="temas">
{#each ficha.temas as tema (tema)}
<li>{tema}</li>
{/each}
</ul>
</section>
{/if}
{#if personas.length > 0}
<section class="bloque">
<h2>Quién lo escribió</h2>
<ul class="personas">
{#each personas as p (p.slug + p.papel)}
<li>
<span class="nombre">{p.nombre}</span>
<span class="vida muted">{procedencia(p)}</span>
<span class="papel muted">{PAPEL[p.papel] ?? p.papel}</span>
</li>
{/each}
</ul>
</section>
{/if}
{#if ficha.rasgos.length > 0}
<section class="bloque">
<h2>Rasgos de escritura</h2>
<ul class="rasgos">
{#each ficha.rasgos as rasgo (rasgo)}
<li>{rasgo}</li>
{/each}
</ul>
</section>
{/if}
{#if obras.length > 0}
<section class="bloque">
<h2>Obras de referencia</h2>
<ul class="obras">
{#each obras as obra (obra.titulo + obra.ano)}
<li>
<span class="ano">{obra.ano ?? '—'}</span>
<div>
<p class="titulo">{obra.titulo}</p>
{#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 data.anterior || data.siguiente}
{#if capitulo.anterior || capitulo.siguiente}
<nav class="vecinos" aria-label="Otros capítulos">
{#if data.anterior}
<a class="vecino" href={resolve('/taller/[slug]', { slug: data.anterior.slug })}>
{#if capitulo.anterior}
<a class="vecino" href={resolve('/taller/[slug]', { slug: capitulo.anterior.slug })}>
<span class="muted">Anterior</span>
<span>{data.anterior.titulo}</span>
<span>{capitulo.anterior.titulo}</span>
</a>
{:else}
<span></span>
{/if}
{#if data.siguiente}
<a class="vecino derecha" href={resolve('/taller/[slug]', { slug: data.siguiente.slug })}>
{#if capitulo.siguiente}
<a class="vecino derecha" href={resolve('/taller/[slug]', { slug: capitulo.siguiente.slug })}>
<span class="muted">Siguiente</span>
<span>{data.siguiente.titulo}</span>
<span>{capitulo.siguiente.titulo}</span>
</a>
{/if}
</nav>
@ -173,6 +391,14 @@
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 {
font-size: var(--fs-lg);
color: var(--c-text-muted);
@ -194,6 +420,38 @@
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 ---------------------------------------------- */
/*
@ -234,8 +492,7 @@
* 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
* 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
* franja de datos de arriba.
* hueco que se ve como un fallo.
*/
.lectura {
display: grid;
@ -247,6 +504,7 @@
.texto {
min-width: 0;
max-width: 62ch;
}
.apartados {
@ -324,17 +582,194 @@
border-bottom: 1px solid var(--c-border);
}
.estructura {
font-family: var(--font-display);
font-size: var(--fs-lg);
max-width: 62ch;
/* Las tres listas comparten caja: filas separadas por una línea de un píxel. */
.instrumentos,
.personas,
.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
* correspondido»— y se leen en rejilla; los rasgos son frases de verdad y se
* leen en lista. Por eso no comparten forma.
* El peso no es adorno: sin él, el tres cubano y una trompeta ocasional
* pesarían igual en la ficha, y no es verdad.
*/
.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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
@ -344,12 +779,19 @@
padding: 0;
}
.temas li {
.temas a {
display: block;
padding: var(--sp-3) var(--sp-4);
background: var(--c-bg-raised);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
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 {
@ -367,6 +809,15 @@
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 {
@ -392,4 +843,12 @@
.vecino.derecha {
text-align: right;
}
@media (max-width: 52rem) {
.instrumentos li,
.personas li {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-1);
}
}
</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.