Llena el ancho de las páginas de texto en vez de ensancharlas

El ancho útil vuelve a 74rem (1184px). Se había subido a 88rem para
aprovechar los monitores anchos y salió al revés: la prosa tiene una
medida de lectura y no crece con el contenedor, así que lo único que
creció fue el hueco a su derecha.

Un vacío al lado de un texto no se tapa estirando el texto —probado: 129
caracteres por línea, ilegible— sino poniendo algo al lado:

- Los textos legales van a tres columnas: los otros documentos, el que se
  lee y sus apartados. El índice de apartados sale del Markdown en bruto
  y las anclas las pone un plugin al compilarlo, así que hay pruebas de
  que las dos formas de calcularlas siguen coincidiendo.
- El índice legal pasa de lista de renglones a rejilla de tarjetas, como
  el taller y los estilos; era el único listado que no se veía como los
  demás.
- La bio lleva al lado lo que hay publicado —temas, discos y estilos, con
  sus enlaces—, sacado del catálogo y no escrito a mano, para que no se
  contradiga sola el día que se suba una canción.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 1 month ago
parent 5c3d3371f0
commit f248ad57da

@ -135,6 +135,52 @@ test.describe('Legal', () => {
await lateral.getByRole('link', { name: 'Condiciones de venta' }).click();
await expect(page).toHaveURL('/legal/condiciones-de-venta');
});
/*
* El índice de apartados sale del Markdown en bruto y las anclas las pone un
* plugin al compilarlo: son dos caminos distintos que tienen que dar la misma
* cadena. Si se separan, los enlaces siguen pintándose y no llevan a ningún
* sitio, que es la clase de avería que no da error y no ve nadie.
*/
for (const slug of [
'aviso-legal',
'condiciones-de-uso',
'privacidad',
'cookies',
'condiciones-de-venta'
]) {
test(`el índice de /legal/${slug} apunta a apartados que existen`, async ({ page }) => {
await page.goto(`/legal/${slug}`);
const indice = page.getByRole('navigation', { name: 'Apartados de esta página' });
const destinos = await indice
.getByRole('link')
.evaluateAll((enlaces) => enlaces.map((e) => e.getAttribute('href') ?? ''));
expect(destinos.length).toBeGreaterThan(2);
for (const destino of destinos) {
await expect(page.locator(`h2${destino}`)).toHaveCount(1);
}
});
}
test('pinchar un apartado lo deja visible bajo la cabecera', async ({ page }) => {
// La cabecera es pegajosa: sin `scroll-margin-top` el salto deja el título
// justo debajo de ella y parece que no ha pasado nada.
await page.goto('/legal/condiciones-de-venta');
const indice = page.getByRole('navigation', { name: 'Apartados de esta página' });
await indice.getByRole('link', { name: 'Derecho de desistimiento' }).click();
const apartado = page.locator('h2#derecho-de-desistimiento');
await expect(apartado).toBeInViewport();
const debajo = await apartado.evaluate((h) => {
const cabecera = document.querySelector('header')!.getBoundingClientRect().bottom;
return h.getBoundingClientRect().top >= cabecera;
});
expect(debajo).toBe(true);
});
});
test.describe('Sin terceros', () => {

@ -138,6 +138,15 @@
*/
--sal: cubic-bezier(0.16, 1, 0.3, 1);
--suave: cubic-bezier(0.4, 0, 0.2, 1);
/*
* El ancho útil: 1184px. Se probó a subirlo a 88rem (1408px) para aprovechar
* los monitores anchos y salió al revés: la prosa tiene una medida de
* lectura y no crece con el contenedor, así que lo único que se ensanchó
* fue el hueco a su derecha. En la página legal se veía a la legua.
*
* Si algún día se ensancha, hay que dar antes a cada texto una columna que
* crezca con él; sin eso, más ancho es más vacío.
*/
--container: 74rem;
/* Alto de la barra del reproductor: lo usa el padding inferior del layout. */

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { ancla, encabezadosDe } from './anclas';
describe('ancla', () => {
it('quita las tildes y la eñe', () => {
expect(ancla('Política de privacidad')).toBe('politica-de-privacidad');
expect(ancla('Años y años')).toBe('anos-y-anos');
});
it('tira la puntuación en vez de dejarla en el ancla', () => {
expect(ancla('¿Qué se recoge, y para qué?')).toBe('que-se-recoge-y-para-que');
});
it('no deja guiones sueltos en los extremos', () => {
expect(ancla(' ¡Pago! ')).toBe('pago');
});
it('da algo con lo que enlazar aunque el título no tenga letras', () => {
expect(ancla('···')).toBe('seccion');
});
it('lleva a la misma ancla el título escrito con tilde y sin ella', () => {
expect(ancla('Facturación')).toBe(ancla('Facturacion'));
});
});
describe('encabezadosDe', () => {
it('recoge los apartados de nivel 2 en orden', () => {
const md = ['# Título', '', '## Primero', 'texto', '', '## Segundo', 'más texto'].join('\n');
expect(encabezadosDe(md)).toEqual([
{ texto: 'Primero', ancla: 'primero' },
{ texto: 'Segundo', ancla: 'segundo' }
]);
});
it('no confunde el frontmatter con contenido', () => {
const md = ['---', 'titulo: Algo', 'nota: "## no es un apartado"', '---', '', '## Real'].join(
'\n'
);
expect(encabezadosDe(md)).toEqual([{ texto: 'Real', ancla: 'real' }]);
});
it('ignora las almohadillas dentro de un bloque de código', () => {
const md = ['## Uno', '', '```sh', '## esto es un comentario', '```', '', '## Dos'].join('\n');
expect(encabezadosDe(md).map((e) => e.texto)).toEqual(['Uno', 'Dos']);
});
it('deja el título limpio de énfasis y de enlaces', () => {
const md = '## El **derecho** de [desistimiento](/legal/condiciones-de-venta)';
expect(encabezadosDe(md)).toEqual([
{ texto: 'El derecho de desistimiento', ancla: 'el-derecho-de-desistimiento' }
]);
});
it('deja fuera los niveles que no pinta el índice', () => {
const md = ['# Uno', '### Tres', '## Dos'].join('\n');
expect(encabezadosDe(md).map((e) => e.texto)).toEqual(['Dos']);
});
});

@ -0,0 +1,73 @@
/**
* Anclas para los encabezados de los textos largos.
*
* Se usa desde dos sitios que tienen que coincidir carácter a carácter: el
* plugin que pone el `id` al compilar el Markdown (en vite.config.ts) y el
* cargador que construye el índice de la página. Si las dos versiones se
* separaran, el índice enlazaría a anclas que no existen y no lo notaría
* nadie hasta pinchar. Por eso vive aquí, en un solo sitio.
*/
/** Convierte el texto de un encabezado en un ancla estable. */
export function ancla(texto: string): string {
return (
texto
.toLowerCase()
// Separa las tildes de la letra para poder tirarlas: «política» y
// «politica» tienen que dar la misma ancla.
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/ñ/g, 'n')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'seccion'
);
}
/** Un encabezado de nivel 2, tal como lo pinta el índice lateral. */
export type Encabezado = { texto: string; ancla: string };
/**
* Saca los encabezados de nivel 2 del Markdown en bruto.
*
* Se lee el texto original y no el HTML compilado porque en el momento de
* cargar la página el cuerpo ya es un componente de Svelte: dejó de ser texto
* y no hay nada que recorrer.
*/
export function encabezadosDe(markdown: string): Encabezado[] {
const encabezados: Encabezado[] = [];
let enBloqueDeCodigo = false;
let enFrontmatter = false;
const lineas = markdown.split(/\r?\n/);
for (const [i, linea] of lineas.entries()) {
// El frontmatter abre y cierra con `---`; dentro puede haber cualquier
// cosa, incluidas almohadillas.
if (linea.trim() === '---') {
if (i === 0) enFrontmatter = true;
else if (enFrontmatter) enFrontmatter = false;
continue;
}
if (enFrontmatter) continue;
// Un `## ` dentro de un bloque de código es código, no un encabezado.
if (/^\s*(```|~~~)/.test(linea)) {
enBloqueDeCodigo = !enBloqueDeCodigo;
continue;
}
if (enBloqueDeCodigo) continue;
const encontrado = /^##\s+(.+?)\s*#*\s*$/.exec(linea);
if (!encontrado) continue;
// Quita el énfasis y los enlaces del título: en el índice sobran.
const texto = encontrado[1]
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_`]/g, '')
.trim();
encabezados.push({ texto, ancla: ancla(texto) });
}
return encabezados;
}

@ -34,6 +34,7 @@ import type {
} from '$lib/types';
import { porFechaDesc } from '$lib/utils/date';
import { slugDesdeRuta } from './fields';
import { encabezadosDe, type Encabezado } from './anclas';
import {
parsearAlbum,
parsearCancion,
@ -63,6 +64,18 @@ const modTaller = import.meta.glob<ModuloMarkdown>('/src/content/taller/*.md', {
const modBlog = import.meta.glob<ModuloMarkdown>('/src/content/blog/*.md', { eager: true });
const modLegal = import.meta.glob<ModuloMarkdown>('/src/content/legal/*.md', { eager: true });
/*
* Los mismos textos legales, pero sin compilar. Se piden dos veces a proposito:
* de la version compilada sale el componente que se pinta, y de esta salen los
* encabezados del indice lateral. Cuando el cuerpo ya es un componente, el
* texto dejo de ser texto y no hay forma de recorrerlo.
*/
const crudoLegal = import.meta.glob('/src/content/legal/*.md', {
eager: true,
query: '?raw',
import: 'default'
}) as Record<string, string>;
/** Aplica un parser al frontmatter de cada archivo del glob. */
function mapear<T>(
glob: Record<string, ModuloMarkdown>,
@ -125,6 +138,12 @@ export function cuerpoLegal(slug: string): Component | undefined {
return cuerpo(modLegal, slug);
}
/** Apartados de una pagina legal, para el indice lateral. */
export function indiceLegal(slug: string): Encabezado[] {
const entrada = Object.entries(crudoLegal).find(([ruta]) => slugDesdeRuta(ruta) === slug);
return entrada ? encabezadosDe(entrada[1]) : [];
}
export function obtenerEntrada(slug: string): EntradaDeBlog | undefined {
return entradas.find((e) => e.slug === slug);
}

@ -1,14 +1,19 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
let { data } = $props();
const Cuerpo = $derived(data.Cuerpo);
/** «catorce temas» se lee mejor que «14 temas» en una ficha corta. */
const plural = (cuantos: number, singular: string, muchos: string) =>
`${cuantos} ${cuantos === 1 ? singular : muchos}`;
</script>
<Meta titulo={data.pagina.titulo} descripcion={data.pagina.descripcion} />
<div class="container">
<div class="container bio">
<article class="texto">
<h1>{data.pagina.titulo}</h1>
{#if data.pagina.descripcion}
@ -19,11 +24,74 @@
<Cuerpo />
{/if}
</article>
<!--
Lo publicado, al lado del texto. Antes esta página era una columna de
prosa sola con media pantalla en blanco a la derecha; y quien acaba de
leer quién escribe las canciones lo siguiente que quiere es oírlas.
-->
<aside class="ficha">
<section>
<p class="rotulo" id="lo-publicado">Lo publicado</p>
<dl aria-labelledby="lo-publicado">
<div>
<dt>Temas</dt>
<dd>{data.totales.canciones}</dd>
</div>
<div>
<dt>Discos</dt>
<dd>{data.totales.albumes}</dd>
</div>
<div>
<dt>Estilos</dt>
<dd>{data.totales.estilos}</dd>
</div>
</dl>
</section>
{#if data.obra.length}
<section>
<p class="rotulo" id="por-estilo">Por estilo</p>
<ul aria-labelledby="por-estilo">
{#each data.obra as estilo (estilo.slug)}
<li>
<a href={resolve('/musica/[estilo]', { estilo: estilo.slug })}>
<span>{estilo.nombre}</span>
<span class="cuantas muted">{plural(estilo.cuantas, 'tema', 'temas')}</span>
</a>
</li>
{/each}
</ul>
</section>
{/if}
<section>
<p class="rotulo">Escribir</p>
<p class="nota muted">
El taller es la parte de esto que se puede enseñar: cómo se arma una letra y por qué unas se
sostienen y otras no.
</p>
<a class="btn btn--ghost" href={resolve('/taller')}>Ver el taller</a>
</section>
</aside>
</div>
<style>
.texto {
max-width: 62ch;
/*
* Las dos columnas miden lo suyo —la prosa su medida de lectura, la ficha lo
* que necesita— y lo que sobra se va al espacio entre ellas.
*
* Con `1fr` en la primera, la columna del texto se estiraba hasta el borde
* y el párrafo se quedaba dentro con su tope: el resultado eran trescientos
* píxeles de nada entre el texto y la ficha, que parecía un fallo. Un
* corredor ancho a propósito se lee como aire; un hueco a un lado, no.
*/
.bio {
display: grid;
grid-template-columns: minmax(0, 62ch) minmax(16rem, 22rem);
justify-content: space-between;
gap: var(--sp-6);
align-items: start;
padding-block: var(--sp-7);
}
@ -31,4 +99,86 @@
font-size: var(--fs-lg);
color: var(--c-text-muted);
}
.ficha {
position: sticky;
top: calc(var(--altura-cabecera) + var(--sp-5));
display: flex;
flex-direction: column;
gap: var(--sp-6);
font-size: var(--fs-sm);
}
.rotulo {
margin: 0 0 var(--sp-2);
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--c-border);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
}
dl {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-2);
margin: 0;
}
dt {
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-text-muted);
}
dd {
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-xl);
line-height: 1.1;
color: var(--c-accent);
}
.ficha ul {
list-style: none;
margin: 0;
padding: 0;
}
.ficha ul a {
display: flex;
justify-content: space-between;
gap: var(--sp-3);
padding-block: var(--sp-2);
border-bottom: 1px solid var(--c-border);
text-decoration: none;
}
.cuantas {
font-size: var(--fs-xs);
white-space: nowrap;
}
.nota {
font-size: var(--fs-sm);
margin-bottom: var(--sp-3);
}
/* Cuando la ficha ya no cabe al lado sin estrangular el texto, va debajo. */
@media (max-width: 64rem) {
.bio {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-6);
}
.ficha {
position: static;
}
dl {
max-width: 24rem;
}
}
</style>

@ -1,9 +1,28 @@
import { error } from '@sveltejs/kit';
import { cuerpoPagina, obtenerPagina } from '$lib/content';
import { albumes, canciones, cuerpoPagina, estilosConObra, obtenerPagina } from '$lib/content';
export function load() {
const pagina = obtenerPagina('bio');
if (!pagina) error(404, 'Falta src/content/paginas/bio.md');
return { pagina, Cuerpo: cuerpoPagina('bio') };
/*
* Al lado del texto va lo que hay publicado. Sale del catálogo y no de un
* campo escrito a mano: una biografía que dice «catorce temas» y una web
* que enseña dieciocho se contradicen el día que se sube uno nuevo, y nadie
* se acuerda de volver aquí a corregirlo.
*/
const obra = estilosConObra()
.filter((e) => e.totalCanciones > 0)
.map((e) => ({
slug: e.estilo.slug,
nombre: e.estilo.nombre,
cuantas: e.totalCanciones
}));
return {
pagina,
Cuerpo: cuerpoPagina('bio'),
obra,
totales: { canciones: canciones.length, albumes: albumes.length, estilos: obra.length }
};
}

@ -1,6 +1,8 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
import { brillo } from '$lib/acciones/brillo';
import { revelar } from '$lib/acciones/revelar';
import { formatearFecha } from '$lib/utils/date';
let { data } = $props();
@ -17,17 +19,21 @@
</header>
<ul class="paginas">
{#each data.legales as pagina (pagina.slug)}
<li>
<a href={resolve('/legal/[slug]', { slug: pagina.slug })}>
<span class="titulo">{pagina.titulo}</span>
{#each data.legales as pagina, i (pagina.slug)}
<li class="tarjeta" use:revelar={i * 45} use:brillo>
<div class="tarjeta__cuerpo">
<span class="titulo">
<a class="tarjeta__enlace" href={resolve('/legal/[slug]', { slug: pagina.slug })}>
{pagina.titulo}
</a>
</span>
<span class="resumen muted">{pagina.resumen}</span>
<span class="fecha muted">
Revisada el <time datetime={pagina.actualizada}
>{formatearFecha(pagina.actualizada)}</time
>
</span>
</a>
</div>
</li>
{/each}
</ul>
@ -48,21 +54,25 @@
color: var(--c-text-muted);
}
/*
* Tarjetas, como los capítulos del taller y los estilos. Antes era una lista
* de renglones a 62 caracteres y dejaba medio ancho de la página en blanco;
* y además era el único listado del sitio que no se veía como los demás.
*/
.paginas {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
gap: var(--sp-4);
list-style: none;
margin: 0;
padding: 0 0 var(--sp-7);
border-top: 1px solid var(--c-border);
max-width: 62ch;
}
.paginas a {
.tarjeta__cuerpo {
display: flex;
flex-direction: column;
gap: var(--sp-1);
padding-block: var(--sp-4);
border-bottom: 1px solid var(--c-border);
text-decoration: none;
gap: var(--sp-2);
height: 100%;
}
.titulo {
@ -70,6 +80,12 @@
font-size: var(--fs-lg);
}
/* La fecha, abajo del todo, para que las tarjetas casen aunque el resumen
ocupe uno o dos renglones. */
.fecha {
margin-top: auto;
}
.resumen {
font-size: var(--fs-sm);
}

@ -54,12 +54,40 @@
{/if}
</article>
</div>
<!--
Los apartados del documento que se está leyendo. Un texto legal se
consulta más que se lee: se entra a mirar el desistimiento o quién trata
los datos, no a leerlo de arriba abajo.
-->
{#if data.indice.length > 1}
<aside class="apartados">
<nav aria-label="Apartados de esta página">
<p class="rotulo" id="lista-apartados">En esta página</p>
<ul aria-labelledby="lista-apartados">
{#each data.indice as apartado (apartado.ancla)}
<li><a href="#{apartado.ancla}">{apartado.texto}</a></li>
{/each}
</ul>
</nav>
</aside>
{/if}
</div>
<style>
/*
* Tres columnas: los otros textos legales, el que se está leyendo, y sus
* apartados.
*
* Se llegó aquí por descarte. Con dos columnas sobraba un cuarto del ancho
* a la derecha y se veía como un fallo de maquetación; estirar el texto
* para taparlo lo dejaba en 129 caracteres por línea, que no hay quien lo
* siga. Un hueco al lado de la prosa no se llena ensanchándola: se llena
* poniendo algo que sirva.
*/
.legal {
display: grid;
grid-template-columns: 14rem minmax(0, 1fr);
grid-template-columns: 13rem minmax(0, 1fr) 13rem;
gap: var(--sp-6);
align-items: start;
}
@ -101,6 +129,28 @@
border-left-color: var(--c-accent);
}
.apartados {
position: sticky;
top: calc(var(--altura-cabecera) + var(--sp-5));
padding-top: var(--sp-7);
font-size: var(--fs-sm);
}
.apartados ul {
list-style: none;
margin: 0;
padding: 0;
}
.apartados a {
display: block;
padding: var(--sp-1) 0;
color: var(--c-text-muted);
text-decoration: none;
/* Los títulos largos ocupan dos renglones; que se lean como un bloque. */
line-height: 1.4;
}
.intro {
padding-bottom: var(--sp-5);
}
@ -114,15 +164,30 @@
margin: var(--sp-1) 0 0;
}
/* Prosa legal: medida corta, y aire para que no parezca letra pequeña. */
/*
* Prosa legal. La columna es ancha, así que la letra sube un punto y el
* interlineado con ella: a igual ancho de columna, cuanto mayor es la letra
* menos caracteres caben por línea, que es lo que hace que se siga.
*/
.texto {
max-width: 66ch;
padding-bottom: var(--sp-7);
/*
* La columna la fija la rejilla, no un `max-width`. Lo que ajusta la
* medida de lectura es el cuerpo de letra: a más grande, menos
* caracteres caben en el mismo ancho.
*/
font-size: 1.125rem;
line-height: 1.75;
}
.texto :global(h2) {
font-size: var(--fs-xl);
margin-top: var(--sp-6);
/*
* Al saltar desde el índice, el apartado quedaría tapado por la cabecera,
* que es pegajosa. Esto lo baja hasta debajo de ella.
*/
scroll-margin-top: calc(var(--altura-cabecera) + var(--sp-4));
}
.texto :global(blockquote) {
@ -156,6 +221,21 @@
color: var(--c-text-muted);
}
/*
* A partir de aquí las tres columnas ya no caben sin estrangular el texto.
* Cae primero la de la derecha: es la que menos falta hace cuando la página
* es corta y se puede recorrer con el pulgar.
*/
@media (max-width: 75rem) {
.legal {
grid-template-columns: 13rem minmax(0, 1fr);
}
.apartados {
display: none;
}
}
@media (max-width: 60rem) {
.legal {
grid-template-columns: minmax(0, 1fr);

@ -1,9 +1,14 @@
import { error } from '@sveltejs/kit';
import { cuerpoLegal, legales, obtenerLegal } from '$lib/content';
import { cuerpoLegal, indiceLegal, legales, obtenerLegal } from '$lib/content';
export function load({ params }) {
const pagina = obtenerLegal(params.slug);
if (!pagina) error(404, 'Esa página no existe.');
return { pagina, legales, Cuerpo: cuerpoLegal(params.slug) };
return {
pagina,
legales,
Cuerpo: cuerpoLegal(params.slug),
indice: indiceLegal(params.slug)
};
}

@ -8,6 +8,7 @@ import { sveltekit } from '@sveltejs/kit/vite';
import type { Plugin } from 'vite';
import type { PreprocessorGroup } from 'svelte/compiler';
import { conExtracto } from './src/lib/content/extracto.ts';
import { ancla } from './src/lib/content/anclas.ts';
/**
* Sirve el panel de contenido en /admin durante el desarrollo.
@ -47,6 +48,40 @@ const inyectarExtracto: PreprocessorGroup = {
}
};
/**
* Pone un `id` a cada encabezado de nivel 2 del Markdown.
*
* Hace falta para que el indice lateral de los textos largos pueda enlazar a
* sus apartados. Se escribe a mano en vez de traer `rehype-slug` porque son
* quince lineas, y porque el ancla tiene que salir de la misma funcion que usa
* el cargador al construir el indice: con dos implementaciones parecidas, un
* dia dejan de coincidir.
*/
type NodoHast = {
type: string;
tagName?: string;
value?: string;
properties?: Record<string, unknown>;
children?: NodoHast[];
};
function textoDe(nodo: NodoHast): string {
if (nodo.type === 'text') return nodo.value ?? '';
return (nodo.children ?? []).map(textoDe).join('');
}
function anclasEnEncabezados() {
return (arbol: NodoHast) => {
const recorrer = (nodo: NodoHast) => {
if (nodo.tagName === 'h2') {
nodo.properties = { ...nodo.properties, id: ancla(textoDe(nodo)) };
}
for (const hijo of nodo.children ?? []) recorrer(hijo);
};
recorrer(arbol);
};
}
/**
* mdsvex 0.12 emite `<script context="module">` para exportar el frontmatter,
* sintaxis que Svelte 5 marca como obsoleta. El aviso apunta a los .md de
@ -88,7 +123,8 @@ export default defineConfig({
// unified, incompatible con la de remark-breaks 4. En ejecucion
// encajan; lo que choca es solo la firma.
// @ts-expect-error tipos de unified desalineados entre mdsvex y remark-breaks
remarkPlugins: [remarkBreaks]
remarkPlugins: [remarkBreaks],
rehypePlugins: [anclasEnEncabezados]
}),
corregirScriptDeMdsvex
],

Loading…
Cancel
Save

Powered by TurnKey Linux.