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.
119 lines
4.0 KiB
119 lines
4.0 KiB
/**
|
|
* Extracto de la letra para las tarjetas de canción.
|
|
*
|
|
* Las tarjetas enseñan los primeros versos de un tema, que es lo que invita a
|
|
* entrar en su ficha. Esos versos están en el cuerpo del Markdown, y el cuerpo
|
|
* se compila a un componente de Svelte: para cuando el listado tiene los datos,
|
|
* el texto ya no existe como texto.
|
|
*
|
|
* La solución es sacarlo antes de compilar. `conExtracto` se ejecuta como
|
|
* preprocesador —antes que mdsvex— y escribe el fragmento en el propio
|
|
* frontmatter, de donde lo recoge el esquema como un campo más. Así el extracto
|
|
* no se mantiene a mano y nunca se desincroniza de la letra.
|
|
*
|
|
* Este modulo no importa nada del proyecto (ni alias `$lib`) a proposito:
|
|
* tambien lo carga `vite.config.ts`, que se evalua fuera de la aplicacion.
|
|
*/
|
|
|
|
export interface OpcionesDeExtracto {
|
|
/** Versos como mucho. */
|
|
maxLineas?: number;
|
|
/** Caracteres como mucho, contando el total. */
|
|
maxCaracteres?: number;
|
|
}
|
|
|
|
/** Marcas de Markdown que no deben verse en una tarjeta. */
|
|
function limpiar(linea: string): string {
|
|
return linea
|
|
.replace(/^\s{0,3}#{1,6}\s+/, '') // encabezados
|
|
.replace(/^\s{0,3}>\s?/, '') // citas
|
|
.replace(/^\s{0,3}[-*+]\s+/, '') // listas
|
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
.replace(/(^|\W)[*_](\S(?:.*?\S)?)[*_](\W|$)/g, '$1$2$3')
|
|
.replace(/`(.+?)`/g, '$1')
|
|
.replace(/\[(.+?)\]\([^)]*\)/g, '$1')
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Primeros versos de un cuerpo de Markdown.
|
|
*
|
|
* Se corta en la primera línea en blanco: la primera estrofa es una unidad de
|
|
* sentido, y partir por la mitad de la segunda queda peor que enseñar menos.
|
|
*/
|
|
export function extraerLineas(cuerpo: string, opciones: OpcionesDeExtracto = {}): string[] {
|
|
const { maxLineas = 4, maxCaracteres = 180 } = opciones;
|
|
|
|
const lineas = cuerpo.replace(/\r\n/g, '\n').split('\n');
|
|
const resultado: string[] = [];
|
|
let total = 0;
|
|
let empezado = false;
|
|
|
|
for (let i = 0; i < lineas.length; i++) {
|
|
const linea = limpiar(lineas[i]);
|
|
|
|
if (linea === '') {
|
|
// Las lineas en blanco de antes del texto se ignoran; la primera de
|
|
// despues cierra la estrofa.
|
|
if (empezado) break;
|
|
continue;
|
|
}
|
|
|
|
empezado = true;
|
|
|
|
if (total + linea.length > maxCaracteres) {
|
|
// Cabe algo de esta linea: se corta por la ultima palabra entera.
|
|
const espacio = maxCaracteres - total;
|
|
if (espacio > 24) {
|
|
const recortada = linea.slice(0, espacio);
|
|
const corte = recortada.lastIndexOf(' ');
|
|
resultado.push(`${(corte > 0 ? recortada.slice(0, corte) : recortada).trim()}…`);
|
|
} else if (resultado.length > 0) {
|
|
resultado[resultado.length - 1] += '…';
|
|
}
|
|
return resultado;
|
|
}
|
|
|
|
resultado.push(linea);
|
|
total += linea.length;
|
|
|
|
if (resultado.length >= maxLineas) {
|
|
// Los puntos suspensivos solo si se corta la estrofa por la mitad. Que
|
|
// el tema siga después no cuenta: la estrofa ha terminado entera.
|
|
if (limpiar(lineas[i + 1] ?? '') !== '') {
|
|
resultado[resultado.length - 1] += '…';
|
|
}
|
|
return resultado;
|
|
}
|
|
}
|
|
|
|
return resultado;
|
|
}
|
|
|
|
const DELIMITADOR = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
|
|
/**
|
|
* Devuelve el archivo con `extracto` añadido al frontmatter.
|
|
*
|
|
* Se respeta un `extracto` escrito a mano, y se deja el archivo intacto si no
|
|
* tiene frontmatter o si el cuerpo está vacío.
|
|
*/
|
|
export function conExtracto(fuente: string, opciones: OpcionesDeExtracto = {}): string {
|
|
const encabezado = DELIMITADOR.exec(fuente);
|
|
if (!encabezado) return fuente;
|
|
|
|
const frontmatter = encabezado[1];
|
|
if (/^extracto\s*:/m.test(frontmatter)) return fuente;
|
|
|
|
const cuerpo = fuente.slice(encabezado[0].length);
|
|
const lineas = extraerLineas(cuerpo, opciones);
|
|
if (lineas.length === 0) return fuente;
|
|
|
|
// JSON es YAML válido en forma de secuencia, así que `JSON.stringify` se
|
|
// encarga del escapado sin tener que reimplementar las reglas de YAML.
|
|
const inyectado = `${frontmatter}\nextracto: ${JSON.stringify(lineas)}`;
|
|
// Con función en vez de cadena: el contenido puede llevar `$&` o `$1`, que
|
|
// en una cadena de reemplazo se interpretarían como referencias.
|
|
return fuente.replace(DELIMITADOR, () => `---\n${inyectado}\n---\n`);
|
|
}
|