/** * Lectores de campos de frontmatter. * * El frontmatter llega como `Record`: YAML sin tipar escrito * a mano. Estas funciones son el unico punto donde ese `unknown` se convierte * en datos tipados, y fallan con un mensaje que dice el archivo y el campo * exactos, para que un error de contenido se arregle sin depurar nada. * * Son funciones puras: se prueban pasandoles objetos planos. */ import { esFechaISO } from '$lib/utils/date'; import { duracionASegundos } from '$lib/utils/format'; export class ErrorDeContenido extends Error { constructor( readonly archivo: string, readonly campo: string, mensaje: string ) { super(`Contenido invalido en ${archivo} -> "${campo}": ${mensaje}`); this.name = 'ErrorDeContenido'; } } type Datos = Record; function falta(valor: unknown): boolean { return valor === undefined || valor === null || valor === ''; } /** Cadena obligatoria y no vacia. */ export function texto(datos: Datos, campo: string, archivo: string): string { const valor = datos[campo]; if (falta(valor)) throw new ErrorDeContenido(archivo, campo, 'es obligatorio'); if (typeof valor !== 'string') { throw new ErrorDeContenido(archivo, campo, `se esperaba texto, llego ${typeof valor}`); } return valor.trim(); } /** Cadena opcional; `undefined` si no esta o esta vacia. */ export function textoOpcional(datos: Datos, campo: string, archivo: string): string | undefined { const valor = datos[campo]; if (falta(valor)) return undefined; if (typeof valor !== 'string') { throw new ErrorDeContenido(archivo, campo, `se esperaba texto, llego ${typeof valor}`); } const limpio = valor.trim(); return limpio === '' ? undefined : limpio; } /** Cadena opcional con valor por defecto. */ export function textoConDefecto( datos: Datos, campo: string, archivo: string, defecto: string ): string { return textoOpcional(datos, campo, archivo) ?? defecto; } /** Booleano opcional. Acepta true/false de YAML, no cadenas. */ export function booleano(datos: Datos, campo: string, archivo: string, defecto = false): boolean { const valor = datos[campo]; if (valor === undefined || valor === null) return defecto; if (typeof valor !== 'boolean') { throw new ErrorDeContenido( archivo, campo, `se esperaba true o false, llego "${String(valor)}"` ); } return valor; } /** Entero opcional dentro de un rango. */ export function entero( datos: Datos, campo: string, archivo: string, opciones: { min?: number; max?: number; defecto?: number } = {} ): number | undefined { const valor = datos[campo]; if (falta(valor)) return opciones.defecto; const n = typeof valor === 'number' ? valor : Number(valor); if (!Number.isInteger(n)) { throw new ErrorDeContenido( archivo, campo, `se esperaba un numero entero, llego "${String(valor)}"` ); } if (opciones.min !== undefined && n < opciones.min) { throw new ErrorDeContenido(archivo, campo, `debe ser mayor o igual que ${opciones.min}`); } if (opciones.max !== undefined && n > opciones.max) { throw new ErrorDeContenido(archivo, campo, `debe ser menor o igual que ${opciones.max}`); } return n; } /** Entero obligatorio. */ export function enteroObligatorio( datos: Datos, campo: string, archivo: string, opciones: { min?: number; max?: number } = {} ): number { if (falta(datos[campo])) throw new ErrorDeContenido(archivo, campo, 'es obligatorio'); return entero(datos, campo, archivo, opciones) as number; } /** * Dia ISO "YYYY-MM-DD". * * YAML convierte una fecha sin comillas en un Date, asi que se acepta tanto el * Date como la cadena; ambos salen normalizados como cadena ISO de 10 * caracteres. Escribirla entrecomillada en el .md tambien funciona. */ export function fecha(datos: Datos, campo: string, archivo: string): string { const valor = datos[campo]; if (falta(valor)) throw new ErrorDeContenido(archivo, campo, 'es obligatorio'); let iso: string; if (valor instanceof Date) { if (Number.isNaN(valor.getTime())) { throw new ErrorDeContenido(archivo, campo, 'no es una fecha valida'); } iso = valor.toISOString().slice(0, 10); } else if (typeof valor === 'string') { iso = valor.trim().slice(0, 10); } else { throw new ErrorDeContenido(archivo, campo, `se esperaba una fecha, llego ${typeof valor}`); } if (!esFechaISO(iso)) { throw new ErrorDeContenido(archivo, campo, `se esperaba una fecha YYYY-MM-DD, llego "${iso}"`); } return iso; } /** * Lista de textos. Acepta una cadena suelta (se envuelve en lista) porque en * la mayoria de los casos la letra o la musica las firma una sola persona. */ export function listaDeTextos( datos: Datos, campo: string, archivo: string, defecto: string[] = [] ): string[] { const valor = datos[campo]; if (falta(valor)) return [...defecto]; if (typeof valor === 'string') return [valor.trim()]; if (!Array.isArray(valor)) { throw new ErrorDeContenido(archivo, campo, 'se esperaba texto o una lista de textos'); } return valor.map((item, i) => { if (typeof item !== 'string' || item.trim() === '') { throw new ErrorDeContenido(archivo, campo, `el elemento ${i + 1} no es texto`); } return item.trim(); }); } /** Valor obligatorio de un conjunto cerrado de opciones. */ export function opcion( datos: Datos, campo: string, archivo: string, opciones: readonly T[], defecto?: T ): T { const valor = datos[campo]; if (falta(valor)) { if (defecto !== undefined) return defecto; throw new ErrorDeContenido(archivo, campo, `es obligatorio (${opciones.join(', ')})`); } if (typeof valor !== 'string' || !opciones.includes(valor as T)) { throw new ErrorDeContenido( archivo, campo, `"${String(valor)}" no es valido; opciones: ${opciones.join(', ')}` ); } return valor as T; } /** * Ruta a un recurso servido desde /static. Se exige la barra inicial para que * no se cuelen rutas relativas, que romperian en las paginas anidadas. */ export function rutaPublica(datos: Datos, campo: string, archivo: string): string | undefined { const valor = textoOpcional(datos, campo, archivo); if (valor === undefined) return undefined; if (!valor.startsWith('/')) { throw new ErrorDeContenido( archivo, campo, `debe empezar por "/" (ruta desde static/), llego "${valor}"` ); } return valor; } /** Duracion "m:ss" convertida a segundos. */ export function duracion(datos: Datos, campo: string, archivo: string): number | undefined { const valor = textoOpcional(datos, campo, archivo); if (valor === undefined) return undefined; const segundos = duracionASegundos(valor); if (segundos === null) { throw new ErrorDeContenido(archivo, campo, `se esperaba una duracion m:ss, llego "${valor}"`); } return segundos; } /** * Mapa de enlaces externos. Las claves desconocidas se descartan en vez de * fallar: anadir una plataforma nueva en un .md no deberia tumbar la * compilacion del sitio entero. */ export function enlaces( datos: Datos, campo: string, archivo: string, permitidas: readonly string[] ): Record { const valor = datos[campo]; if (falta(valor)) return {}; if (typeof valor !== 'object' || Array.isArray(valor)) { throw new ErrorDeContenido(archivo, campo, 'se esperaba un mapa plataforma: url'); } const resultado: Record = {}; for (const [clave, url] of Object.entries(valor as Record)) { if (!permitidas.includes(clave)) continue; if (typeof url !== 'string' || !/^https?:\/\//.test(url)) { throw new ErrorDeContenido(archivo, `${campo}.${clave}`, 'debe ser una URL http(s)'); } resultado[clave] = url; } return resultado; } /** * Clave de un archivo dentro del almacen privado de audio. * * A diferencia de `rutaPublica`, aqui se exige justo lo contrario: una ruta * *relativa*, sin barra inicial. El audio completo no se sirve desde /static, * asi que el frontmatter no puede contener una URL publica; lo que guarda es * una clave que el servidor resuelve contra el directorio privado. */ export function claveAudio(datos: Datos, campo: string, archivo: string): string | undefined { const valor = textoOpcional(datos, campo, archivo); if (valor === undefined) return undefined; if (valor.startsWith('/')) { throw new ErrorDeContenido( archivo, campo, `no debe empezar por "/": el audio completo no es publico. Usa una clave relativa como "album/01-tema.mp3", llego "${valor}"` ); } // Una clave con ".." se saldria del directorio privado al resolverla. if (!/^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*\.[a-z0-9]{2,4}$/i.test(valor)) { throw new ErrorDeContenido( archivo, campo, `no es una clave de audio valida (letras, digitos, ".", "-", "_" y "/", con extension), llego "${valor}"` ); } return valor; } /** * Texto largo del frontmatter partido en parrafos. * * Se escribe como bloque YAML (`campo: |`) y se separa por lineas en blanco. * No admite Markdown a proposito: es prosa corrida, y meter un compilador de * Markdown para el frontmatter no compensa. */ export function parrafos(datos: Datos, campo: string, archivo: string): string[] { const valor = datos[campo]; if (falta(valor)) return []; if (typeof valor !== 'string') { throw new ErrorDeContenido(archivo, campo, `se esperaba texto, llego ${typeof valor}`); } return valor .split(/\n\s*\n/) .map((parrafo) => parrafo.trim().replace(/\s*\n\s*/g, ' ')) .filter((parrafo) => parrafo !== ''); } /** Lista de objetos del frontmatter (por ejemplo, las versiones de un tema). */ export function listaDeObjetos(datos: Datos, campo: string, archivo: string): Datos[] { const valor = datos[campo]; if (falta(valor)) return []; if (!Array.isArray(valor)) { throw new ErrorDeContenido(archivo, campo, 'se esperaba una lista'); } return valor.map((item, i) => { if (typeof item !== 'object' || item === null || Array.isArray(item)) { throw new ErrorDeContenido(archivo, `${campo}[${i}]`, 'se esperaba un objeto'); } return item as Datos; }); } /** * Codigo ISWC, que identifica la OBRA (la composicion) ante las entidades de * gestion. Formato T-DDD.DDD.DDD-C; se admiten los puntos y guiones opcionales * y se devuelve siempre normalizado a "T-DDD.DDD.DDD-C". */ export function codigoISWC(datos: Datos, campo: string, archivo: string): string | undefined { const valor = textoOpcional(datos, campo, archivo); if (valor === undefined) return undefined; const limpio = valor.toUpperCase().replace(/[\s.-]/g, ''); if (!/^T\d{10}$/.test(limpio)) { throw new ErrorDeContenido( archivo, campo, `se esperaba un ISWC (T-DDD.DDD.DDD-C), llego "${valor}"` ); } const d = limpio.slice(1); return `T-${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6, 9)}-${d.slice(9)}`; } /** * Codigo ISRC, que identifica una GRABACION concreta (no la obra: dos * versiones del mismo tema comparten ISWC y tienen ISRC distintos). * Formato CC-XXX-YY-NNNNN. */ export function codigoISRC(datos: Datos, campo: string, archivo: string): string | undefined { const valor = textoOpcional(datos, campo, archivo); if (valor === undefined) return undefined; const limpio = valor.toUpperCase().replace(/[\s-]/g, ''); if (!/^[A-Z]{2}[A-Z0-9]{3}\d{7}$/.test(limpio)) { throw new ErrorDeContenido( archivo, campo, `se esperaba un ISRC (CC-XXX-YY-NNNNN), llego "${valor}"` ); } return `${limpio.slice(0, 2)}-${limpio.slice(2, 5)}-${limpio.slice(5, 7)}-${limpio.slice(7)}`; } /** * Identificador de un video de YouTube. * * Acepta el id suelto o cualquiera de las formas de enlace que se copian del * navegador (`watch?v=`, `youtu.be/`, `/embed/`, `/shorts/`), porque nadie * tiene por que saber cual es el id: se pega lo que hay en la barra y ya. * Devuelve siempre el id, que es lo unico que necesita el reproductor. */ export function idYoutube(datos: Datos, campo: string, archivo: string): string { const valor = texto(datos, campo, archivo); const formas = [ /^([A-Za-z0-9_-]{11})$/, /[?&]v=([A-Za-z0-9_-]{11})/, /youtu\.be\/([A-Za-z0-9_-]{11})/, /\/embed\/([A-Za-z0-9_-]{11})/, /\/shorts\/([A-Za-z0-9_-]{11})/, /\/live\/([A-Za-z0-9_-]{11})/ ]; for (const forma of formas) { const encontrado = forma.exec(valor); if (encontrado) return encontrado[1]; } throw new ErrorDeContenido( archivo, campo, `no se reconoce como video de YouTube; pega el enlace o el id, llego "${valor}"` ); } /** Porcentaje entre 0 y 100, con hasta dos decimales. */ export function porcentaje(datos: Datos, campo: string, archivo: string): number { const valor = datos[campo]; if (falta(valor)) throw new ErrorDeContenido(archivo, campo, 'es obligatorio'); const n = typeof valor === 'number' ? valor : Number(valor); if (!Number.isFinite(n) || n <= 0 || n > 100) { throw new ErrorDeContenido( archivo, campo, `se esperaba un porcentaje entre 0 y 100, llego "${String(valor)}"` ); } return Math.round(n * 100) / 100; } /** Extrae el slug del nombre de archivo: ".../mi-tema.md" -> "mi-tema". */ export function slugDesdeRuta(ruta: string): string { const nombre = ruta.split('/').pop() ?? ruta; return nombre.replace(/\.(md|svx)$/, ''); }