You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
407 lines
13 KiB
407 lines
13 KiB
/**
|
|
* Lectores de campos de frontmatter.
|
|
*
|
|
* El frontmatter llega como `Record<string, unknown>`: 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<string, unknown>;
|
|
|
|
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<T extends string>(
|
|
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<string, string> {
|
|
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<string, string> = {};
|
|
for (const [clave, url] of Object.entries(valor as Record<string, unknown>)) {
|
|
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)$/, '');
|
|
}
|