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/schema.ts

323 lines
10 KiB

/**
* Esquemas de contenido: convierten el frontmatter de cada tipo de archivo
* Markdown en las entidades tipadas de `$lib/types`.
*
* Son funciones puras (frontmatter + nombre de archivo -> entidad), asi que se
* prueban sin tocar el sistema de archivos ni Vite.
*/
import type {
Album,
Cancion,
CapituloDeTaller,
FichaDeEstilo,
EdicionDeAlbum,
EntradaDeBlog,
Estilo,
PaginaMeta,
ParteDeAutoria,
RegistroDeObra,
VersionMusical,
VideoDeCancion
} from '$lib/types';
import { site } from '$lib/site';
import {
booleano,
claveAudio,
codigoISRC,
codigoISWC,
duracion,
enlaces,
entero,
enteroObligatorio,
ErrorDeContenido,
fecha,
idYoutube,
listaDeObjetos,
listaDeTextos,
opcion,
parrafos,
porcentaje,
rutaPublica,
texto,
textoConDefecto,
textoOpcional
} from './fields';
/** Plataformas aceptadas en el campo `enlaces` de albumes y canciones. */
const PLATAFORMAS = [
'spotify',
'appleMusic',
'youtube',
'youtubeMusic',
'bandcamp',
'soundcloud',
'deezer',
'tidal',
'amazonMusic'
] as const;
const TIPOS_ALBUM = ['album', 'ep', 'single', 'coleccion'] as const;
type Datos = Record<string, unknown>;
/** Portada de reserva para albumes sin imagen propia. */
export const PORTADA_POR_DEFECTO = '/images/sin-portada.svg';
/** Portada del album sintetico de singles. */
export const PORTADA_SINGLES = '/images/singles/portada.svg';
/**
* Normaliza un texto como identificador: sin tildes, en minusculas y con
* guiones. Se usa para dar id a las versiones que no lo declaran.
*/
export function aSlug(texto: string): string {
return texto
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function parsearEstilo(datos: Datos, slug: string, archivo: string): Estilo {
return {
slug,
nombre: texto(datos, 'nombre', archivo),
resumen: textoConDefecto(datos, 'resumen', archivo, ''),
// Sin `orden` explicito, el estilo cae al final de los bloques.
orden: entero(datos, 'orden', archivo, { min: 0, defecto: 999 }) as number,
principal: booleano(datos, 'principal', archivo),
imagen: rutaPublica(datos, 'imagen', archivo)
};
}
export function parsearAlbum(datos: Datos, slug: string, archivo: string): Album {
return {
slug,
titulo: texto(datos, 'titulo', archivo),
estilo: texto(datos, 'estilo', archivo),
fecha: fecha(datos, 'fecha', archivo),
tipo: opcion(datos, 'tipo', archivo, TIPOS_ALBUM, 'album'),
portada: rutaPublica(datos, 'portada', archivo) ?? PORTADA_POR_DEFECTO,
sello: textoOpcional(datos, 'sello', archivo),
resumen: textoConDefecto(datos, 'resumen', archivo, ''),
destacado: booleano(datos, 'destacado', archivo),
enlaces: enlaces(datos, 'enlaces', archivo, PLATAFORMAS),
sintetico: false,
edicion: parsearEdicion(datos, archivo)
};
}
const ROLES_AUTORIA = ['letra', 'musica', 'letra y musica'] as const;
const FORMATOS_EDICION = ['digital', 'cd', 'vinilo', 'casete'] as const;
/**
* Reparto de autoria de una obra.
*
* Se comprueba que sume 100: un reparto que no cuadra es un error que solo se
* descubre cuando la entidad de gestion rechaza el registro, y para entonces
* ya no se sabe de donde salio el numero.
*/
function parsearReparto(datos: Datos, archivo: string): ParteDeAutoria[] {
const partes = listaDeObjetos(datos, 'reparto', archivo).map((parte, i) => {
const donde = `${archivo} (reparto[${i}])`;
return {
nombre: texto(parte, 'nombre', donde),
rol: opcion(parte, 'rol', donde, ROLES_AUTORIA, 'letra y musica'),
porcentaje: porcentaje(parte, 'porcentaje', donde)
};
});
if (partes.length === 0) return [];
// Con dos decimales por parte, la suma puede desviarse por redondeo.
const suma = partes.reduce((total, p) => total + p.porcentaje, 0);
if (Math.abs(suma - 100) > 0.01) {
throw new ErrorDeContenido(
archivo,
'reparto',
`los porcentajes suman ${suma}, y una obra reparte exactamente 100`
);
}
return partes;
}
function parsearRegistro(datos: Datos, archivo: string): RegistroDeObra {
const registro = (datos.registro ?? {}) as Datos;
if (typeof registro !== 'object' || Array.isArray(registro)) {
throw new ErrorDeContenido(archivo, 'registro', 'se esperaba un mapa de datos');
}
return {
iswc: codigoISWC(registro, 'iswc', archivo),
isrc: codigoISRC(registro, 'isrc', archivo),
entidad: textoOpcional(registro, 'entidad', archivo),
numero: textoOpcional(registro, 'numero', archivo),
editorial: textoOpcional(registro, 'editorial', archivo),
reparto: parsearReparto(registro, archivo)
};
}
function parsearEdicion(datos: Datos, archivo: string): EdicionDeAlbum {
const edicion = (datos.edicion ?? {}) as Datos;
if (typeof edicion !== 'object' || Array.isArray(edicion)) {
throw new ErrorDeContenido(archivo, 'edicion', 'se esperaba un mapa de datos');
}
const formatos = listaDeTextos(edicion, 'formatos', archivo).map((formato, i) =>
opcion({ formato }, 'formato', `${archivo} (edicion.formatos[${i}])`, FORMATOS_EDICION)
);
return {
numeroCatalogo: textoOpcional(edicion, 'numeroCatalogo', archivo),
formatos,
pais: textoOpcional(edicion, 'pais', archivo),
// Lista abierta: cada disco acredita cometidos distintos y encerrarlos
// en un conjunto fijo solo obligaria a ampliarlo cada vez.
creditos: listaDeObjetos(edicion, 'creditos', archivo).map((credito, i) => {
const donde = `${archivo} (edicion.creditos[${i}])`;
return { rol: texto(credito, 'rol', donde), nombre: texto(credito, 'nombre', donde) };
})
};
}
function parsearVideos(datos: Datos, archivo: string): VideoDeCancion[] {
return listaDeObjetos(datos, 'videos', archivo).map((video, i) => {
const donde = `${archivo} (videos[${i}])`;
return {
youtube: idYoutube(video, 'youtube', donde),
titulo: textoConDefecto(video, 'titulo', donde, 'Vídeo')
};
});
}
/** Una alternativa musical dentro de la lista `versiones` de un tema. */
function parsearVersion(datos: Datos, indice: number, archivo: string): VersionMusical {
const campo = `versiones[${indice}]`;
const nombre = texto(datos, 'nombre', `${archivo} (${campo})`);
const audio = claveAudio(datos, 'audio', `${archivo} (${campo})`);
if (!audio) {
throw new ErrorDeContenido(archivo, `${campo}.audio`, 'es obligatorio');
}
return {
// Sin `id` explicito se deriva del nombre: basta para distinguirlas
// dentro del tema, que es el unico ambito en el que viven.
id: textoOpcional(datos, 'id', `${archivo} (${campo})`) ?? aSlug(nombre),
nombre,
audio,
duracion: duracion(datos, 'duracion', `${archivo} (${campo})`),
nota: textoOpcional(datos, 'nota', `${archivo} (${campo})`)
};
}
export function parsearCancion(datos: Datos, slug: string, archivo: string): Cancion {
const autoria = [site.nombre];
const album = textoOpcional(datos, 'album', archivo) ?? null;
const audio = claveAudio(datos, 'audio', archivo);
const versiones = listaDeObjetos(datos, 'versiones', archivo).map((v, i) =>
parsearVersion(v, i, archivo)
);
const ids = new Set<string>();
for (const version of versiones) {
if (ids.has(version.id)) {
throw new ErrorDeContenido(
archivo,
'versiones',
`hay dos versiones con el mismo id "${version.id}"`
);
}
ids.add(version.id);
}
return {
slug,
titulo: texto(datos, 'titulo', archivo),
estilo: texto(datos, 'estilo', archivo),
album,
// El numero de pista solo tiene sentido dentro de un disco; un single
// suelto no ocupa ninguna posicion.
numero: album === null ? null : enteroObligatorio(datos, 'numero', archivo, { min: 1 }),
audio,
duracion: duracion(datos, 'duracion', archivo),
ano: entero(datos, 'ano', archivo, { min: 1900, max: 2200 }),
// La autoria por defecto es del artista: es su propia web.
letra: listaDeTextos(datos, 'letra', archivo, autoria),
musica: listaDeTextos(datos, 'musica', archivo, autoria),
interpretes: listaDeTextos(datos, 'interpretes', archivo),
destacada: booleano(datos, 'destacada', archivo),
etiquetas: listaDeTextos(datos, 'etiquetas', archivo),
enlaces: enlaces(datos, 'enlaces', archivo, PLATAFORMAS),
instrumental: booleano(datos, 'instrumental', archivo),
idioma: textoConDefecto(datos, 'idioma', archivo, 'es'),
traduccion: textoOpcional(datos, 'traduccion', archivo),
portada: rutaPublica(datos, 'portada', archivo),
inspiracion: parrafos(datos, 'inspiracion', archivo),
// Lo pone el preprocesador `extracto-de-cancion`, no la persona que
// escribe el contenido; se admite escrito a mano para poder afinarlo.
extracto: listaDeTextos(datos, 'extracto', archivo),
versiones,
// Sin archivo de audio no hay nada que entregar, asi que no se vende.
alaVenta: booleano(datos, 'alaVenta', archivo, audio !== undefined),
registro: parsearRegistro(datos, archivo),
videos: parsearVideos(datos, archivo)
};
}
const TIPOS_CAPITULO = ['principio', 'ficha'] as const;
function parsearFicha(datos: Datos, archivo: string): FichaDeEstilo {
const ficha = (datos.ficha ?? {}) as Datos;
if (typeof ficha !== 'object' || Array.isArray(ficha)) {
throw new ErrorDeContenido(archivo, 'ficha', 'se esperaba un mapa de datos');
}
return {
origen: textoOpcional(ficha, 'origen', archivo),
compas: textoOpcional(ficha, 'compas', archivo),
tempo: textoOpcional(ficha, 'tempo', archivo),
metrica: textoOpcional(ficha, 'metrica', archivo),
estructura: textoOpcional(ficha, 'estructura', archivo),
rima: textoOpcional(ficha, 'rima', archivo),
temas: listaDeTextos(ficha, 'temas', archivo),
rasgos: listaDeTextos(ficha, 'rasgos', archivo)
};
}
export function parsearCapitulo(datos: Datos, slug: string, archivo: string): CapituloDeTaller {
return {
slug,
titulo: texto(datos, 'titulo', archivo),
tipo: opcion(datos, 'tipo', archivo, TIPOS_CAPITULO, 'principio'),
orden: entero(datos, 'orden', archivo, { min: 0, defecto: 999 }) as number,
resumen: textoConDefecto(datos, 'resumen', archivo, ''),
ficha: parsearFicha(datos, archivo)
};
}
export function parsearEntrada(datos: Datos, slug: string, archivo: string): EntradaDeBlog {
return {
slug,
titulo: texto(datos, 'titulo', archivo),
fecha: fecha(datos, 'fecha', archivo),
resumen: textoConDefecto(datos, 'resumen', archivo, ''),
imagen: rutaPublica(datos, 'imagen', archivo),
etiquetas: listaDeTextos(datos, 'etiquetas', archivo),
canciones: listaDeTextos(datos, 'canciones', archivo),
borrador: booleano(datos, 'borrador', archivo)
};
}
export function parsearPagina(datos: Datos, slug: string, archivo: string): PaginaMeta {
return {
slug,
titulo: texto(datos, 'titulo', archivo),
descripcion: textoConDefecto(datos, 'descripcion', archivo, '')
};
}

Powered by TurnKey Linux.