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.
323 lines
10 KiB
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, '')
|
|
};
|
|
}
|