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.
senzapaura_es/src/lib/content/fields.ts

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)$/, '');
}

Powered by TurnKey Linux.