La puerta del panel, y una portada que dice qué falta

Fase 6. Está lo que todo lo demás necesita: quién entra y por dónde.

El rol vive en `usuario.rol`, con un CHECK que impide inventarse uno, y se da
con `npm run db:admin`. En una variable de entorno con correos habría sido más
rápido, pero entonces revocar a alguien pide un despliegue y a la pregunta
«quién administra esto» solo sabe contestar quien pueda leer la configuración
del servidor.

La guarda está en el `+layout.server.ts` de la carpeta y no en cada ruta: una
pantalla nueva queda protegida por el hecho de estar dentro. Sin sesión, a
entrar con la vuelta puesta; con sesión y sin el rol, 404 y no 403, porque un
403 confirma que el panel existe y dónde vive. Los tres casos, comprobados
contra la aplicación de verdad.

La portada contesta «qué falta» y no «cuánto hay». Cada sección trae lo que
tiene, lo que está a medias y los nombres de lo que está a medias: decir «1
género sin ficha» obliga a ir a buscar cuál, y esa búsqueda ya la ha hecho la
consulta. Dos viajes a la base para las siete filas.

Fuera Sveltia CMS y `static/admin/`. Editaba los Markdown de `src/content/`,
que desde la fase 4 no lee nadie: guardaba cambios que no salían en pantalla, y
además tapaba la ruta nueva.

Y de paso, la prueba de contacto que fallaba en la tanda completa y pasaba a
solas. No era aislamiento: `page.goto` vuelve al cargar, no al hidratar, y al
hidratar Svelte reescribe el valor de cada `input` con el del componente. Lo
tecleado antes de ese instante se perdía, y solo le pasaba al primer campo y
solo con la máquina cargada.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent 174cf828e7
commit 3981f33867

@ -96,53 +96,44 @@ entrar en una cuenta sin configurar nada.
| `npm run db:migrate` | Aplica las migraciones pendientes |
| `npm run db:studio` | Abre el visor de la base de datos |
## El panel de contenido
## El panel
En `/admin` hay un panel ([Sveltia CMS](https://sveltiacms.app)) para editar el
catálogo sin tocar archivos: formularios por tipo de contenido, con desplegables
para elegir el estilo o el disco de una canción en vez de escribir su slug.
En `/admin` está la administración del contenido. Su portada dice qué hay y qué
falta —capítulos sin publicar, géneros sin ficha, marcas del glosario sin
confirmar—, que es la pregunta que se hace quien entra.
Sube también el **audio** y las **portadas**: el máster va a una bandeja y
`npm run medios` lo coloca al arrancar (más abajo, en «Dónde va el audio»).
Antes vivía aquí [Sveltia CMS](https://sveltiacms.app), un editor sobre git que
escribía los Markdown de `src/content/`. Se quitó al terminar la migración a
PostgreSQL: **el sitio ya no lee esos archivos**, así que el panel seguía
guardando cambios que no salían en pantalla, y eso es peor que no tener panel.
**Escribe los mismos Markdown de `src/content/`.** Esa es la razón de haberlo
elegido en lugar de mover el catálogo a una base de datos: se gana el panel sin
perder nada de lo que ya funcionaba. El esquema los sigue validando al
compilar, git sigue guardando el historial de cada letra —que en una web de un
letrista es el patrimonio— y el catálogo sigue sin hacer ni una consulta,
porque va compilado en el bundle.
### Quién entra
### Editar en local
El rol vive en la base, en `usuario.rol`, y se da con un script:
El flujo local de Sveltia usa la File System Access API del navegador, sin
servidor intermedio. Requiere dos cosas:
1. Un **navegador basado en Chromium** (Chrome, Edge, Brave). Firefox y Safari
todavía no traen esa API.
2. Que el proyecto sea un **repositorio git**. Ya lo es.
Con `npm run dev` en marcha, se abre `http://localhost:5173/admin`, se pulsa
«Trabajar con un repositorio local» y se elige la carpeta del proyecto. A partir
de ahí, guardar en el panel escribe el archivo, y el sitio se recarga solo.
### Editar desde cualquier sitio
```bash
npm run db:admin # quién administra ahora
npm run db:admin -- hola@senzapaura.es # se lo da
npm run db:admin -- hola@… -- --quitar # se lo quita
```
Para eso hace falta que el repositorio esté en GitHub y cambiar `repo` en
[`static/admin/config.yml`](static/admin/config.yml) por el nombre real. Con eso,
el panel funciona desde el navegador de cualquier equipo y cada guardado es un
commit.
La cuenta tiene que existir: se da el rol a alguien que ya ha entrado alguna vez,
para que no se quede esperando en una dirección mal escrita. El rol se lee de la
base en cada petición, así que darlo o quitarlo surte efecto en la página
siguiente, sin echar a nadie fuera.
### Al añadir un campo
Es un script y no una pantalla del panel por dos razones: el primer
administrador no puede nombrarse desde un panel al que todavía no entra nadie,
y mientras administre una sola persona, una pantalla de permisos sería mantener
un sistema de permisos para un usuario.
`config.yml` replica el esquema de
[`schema.ts`](src/lib/content/schema.ts). **Si añades un campo allí, añádelo
también aquí**: el panel guarda lo que conoce, así que un campo que no esté
declarado se pierde al editar esa entrada desde el panel.
### Qué pasa si no eres tú
Donde el panel puede, comprueba lo mismo que el esquema: el formato del ISWC y
el ISRC, la duración como `m:ss`, la ruta relativa del audio y el rango de los
porcentajes. Lo que no puede comprobar —que un reparto sume exactamente 100— lo
sigue cazando la compilación.
Sin sesión, a `/entrar`, con la dirección de vuelta puesta. Con sesión pero sin
el rol, **404 y no 403**: un 403 confirma que el panel existe y dónde vive, y
quien entró a comprar una canción no tiene por qué enterarse. La guarda está en
el `+layout.server.ts` de la carpeta, así que una pantalla nueva queda protegida
por el hecho de estar dentro.
## Cómo se organiza el contenido

@ -465,9 +465,11 @@ ya cargado.
## Qué cambia en la web
1. **Sveltia CMS deja de servir.** Es un editor sobre git. Hasta que exista la
administración propia —decidido: para después—, el contenido se carga por
script y por SQL.
1. **Sveltia CMS deja de servir.** Es un editor sobre git: escribía los Markdown
de `src/content/`, que ya no lee nadie. **Retirado** al empezar la fase 6, y
con él `static/admin/`; un panel que guarda cambios que no salen en pantalla
es peor que no tener panel. Mientras no estén las pantallas de editar, el
contenido se carga por script y por SQL.
2. **El Markdown se compone en cada petición**, no al construir. Se cachea.
3. **Cada página consulta la base.** A cambio, cambiar una letra deja de exigir
un despliegue.
@ -520,7 +522,25 @@ no cambia nada en pantalla**. `$lib/content` solo lo usan los scripts.
persona confirma, y hasta que exista la administración manda la primera
acepción —que es la razón de que estén numeradas por orden de importancia—.
6. **La administración.**
6. **La administración.** _En curso._ Está la puerta y está la portada.
El rol vive en `usuario.rol`, con un `CHECK` que impide inventarse uno, y se
da con `npm run db:admin`. En una variable de entorno con correos habría sido
más rápido, pero entonces revocar a alguien pide un despliegue y a «quién
administra esto» solo sabe contestar quien lea la configuración del servidor.
La guarda está en el `+layout.server.ts` de `/admin` y no en cada ruta: así
una pantalla nueva queda protegida por estar dentro de la carpeta. Sin sesión
manda a entrar con la vuelta puesta; con sesión y sin el rol, **404 y no
403**, que un 403 confirma que el panel existe y dónde vive.
La portada contesta «qué falta» y no «cuánto hay»: cada sección trae lo que
tiene, lo que está a medias y **los nombres** de lo que está a medias. Decir
«1 género sin ficha» obliga a ir a buscar cuál, y esa búsqueda ya la ha hecho
la consulta.
Faltan las pantallas de editar: glosario —confirmar las acepciones marcadas,
que es lo que tiene `capitulo_acepcion` esperando—, capítulos y géneros.
## Decisiones tomadas por defecto

@ -0,0 +1,17 @@
-- Quién administra el sitio.
--
-- El rol va en la base y no en una variable de entorno con una lista de
-- correos. Con la variable, revocarle el acceso a alguien pide un despliegue, y
-- a la pregunta «quién administra esto» solo sabe contestar quien pueda leer la
-- configuración del servidor.
--
-- El `CHECK` no es adorno. La columna es texto, así que sin él un `UPDATE`
-- escrito a mano con «adminn» dejaría una cuenta en un rol que no existe: ni
-- administra ni queda registrada como que no. Un rol desconocido tiene que ser
-- imposible, no improbable.
--
-- `persona` por defecto, y las cuentas que ya existen se quedan ahí: encender
-- esta columna no puede dar permisos a nadie.
ALTER TABLE "usuario" ADD COLUMN "rol" text DEFAULT 'persona' NOT NULL;--> statement-breakpoint
ALTER TABLE "usuario" ADD CONSTRAINT "usuario_rol_conocido" CHECK ("rol" IN ('persona', 'admin'));

@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
/*
* El panel, desde fuera.
*
* Aquí solo se comprueba la puerta: que sin sesión no se ve nada y que la
* dirección de vuelta se conserva. Quién puede pasar una vez dentro lo deciden
* `esAdmin` y `exigirAdmin`, y eso se prueba en `administracion.spec.ts`, donde
* se pueden montar las tres situaciones sin necesitar un buzón de correo.
*/
test.describe('El panel', () => {
test('sin sesión no se ve, se manda a entrar', async ({ page }) => {
await page.goto('/admin');
await expect(page).toHaveURL(/\/entrar/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
test('y se vuelve a donde se iba, no a la portada', async ({ page }) => {
await page.goto('/admin?ver=pendientes');
const url = new URL(page.url());
expect(url.pathname).toBe('/entrar');
expect(url.searchParams.get('volverA')).toBe('/admin?ver=pendientes');
});
test('el panel no es contenido del sitio, y robots lo dice', async ({ request }) => {
const robots = await request.get('/robots.txt');
expect(await robots.text()).toContain('Disallow: /admin');
});
test('no hay ningún enlace al panel en la web pública', async ({ page }) => {
// No es seguridad —la guarda es el servidor—, es que el panel no forma
// parte de la navegación de nadie que venga a leer o a comprar.
for (const ruta of ['/', '/musica', '/generos', '/blog']) {
await page.goto(ruta);
await expect(page.locator('a[href^="/admin"]')).toHaveCount(0);
}
});
});

@ -1,16 +1,37 @@
import { expect, test } from '@playwright/test';
import { expect, test, type Locator, type Page } from '@playwright/test';
/**
* Escribe en un campo y comprueba que se ha quedado escrito.
*
* El bucle no es paranoia. `page.goto` vuelve cuando la página ha cargado, no
* cuando SvelteKit la ha hidratado, y al hidratar, Svelte reescribe el valor de
* cada `input` con el del componente, que está vacío. Lo que se teclee antes de
* ese instante desaparece.
*
* Solo le pasaba al primer campo —los demás se rellenan milisegundos después,
* ya hidratada— y solo con la máquina cargada, así que fallaba en la tanda
* completa y pasaba al repetir la prueba a solas. Es la peor clase de fallo:
* el que parece un fantasma.
*/
async function escribir(campo: Locator, valor: string) {
await expect(async () => {
await campo.fill(valor);
await expect(campo).toHaveValue(valor, { timeout: 500 });
}).toPass({ timeout: 10_000 });
}
/** Rellena el formulario con datos válidos, salvo lo que se sobrescriba. */
async function rellenar(
page: import('@playwright/test').Page,
page: Page,
campos: Partial<{ nombre: string; email: string; motivo: string; mensaje: string }> = {}
) {
await page.getByLabel('Nombre').fill(campos.nombre ?? 'Ana Ruiz');
await page.getByLabel('Correo electrónico').fill(campos.email ?? 'ana@example.com');
await escribir(page.getByLabel('Nombre'), campos.nombre ?? 'Ana Ruiz');
await escribir(page.getByLabel('Correo electrónico'), campos.email ?? 'ana@example.com');
await page.getByLabel('Motivo').selectOption(campos.motivo ?? 'colaboracion');
await page
.getByLabel('Mensaje')
.fill(campos.mensaje ?? 'Me gustaría hablar contigo sobre una colaboración para un disco.');
await escribir(
page.getByLabel('Mensaje'),
campos.mensaje ?? 'Me gustaría hablar contigo sobre una colaboración para un disco.'
);
}
test.describe('Formulario de contacto', () => {

@ -23,6 +23,7 @@
"predev": "node scripts/medios.mjs",
"prebuild": "node scripts/medios.mjs",
"db:guardas": "node --env-file=.env scripts/db/comprobar-guardas.mjs",
"db:admin": "node --env-file=.env scripts/db/hacer-admin.mjs",
"db:volcar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/volcar.mjs",
"db:medios": "node --env-file=.env scripts/db/volcar-medios.mjs",
"db:generos": "node --env-file=.env scripts/db/sembrar-generos.mjs",

@ -224,6 +224,10 @@ await rechaza(
values ('f1', 'inexistente', 'A', 1, 'rectificativa', 100, 21, 21, 121, '{}', 'ZZ')`
);
apuntar('\nEL ROL DE LA CUENTA');
await rechaza('no se puede inventar un rol', `update usuario set rol = 'adminn' where id = 'u9'`);
await permite('y quien administra, sí', `update usuario set rol = 'admin' where id = 'u9'`);
apuntar('\nLA VALORACIÓN');
await rechaza(
'no se puede puntuar con un seis',

@ -0,0 +1,74 @@
/**
* Da o quita el rol de administrador.
*
* npm run db:admin # quién administra ahora
* npm run db:admin -- hola@senzapaura.es # se lo da
* npm run db:admin -- hola@… --quitar # se lo quita
*
* Es un script y no una pantalla del panel a propósito: el primer administrador
* no puede nombrarse desde un panel al que todavía no puede entrar nadie. Y
* mientras administre una sola persona, una pantalla para gestionar permisos
* sería mantener un sistema de permisos para un usuario.
*
* No crea la cuenta. Se da el rol a alguien que ya ha entrado alguna vez, para
* que el rol no pueda quedarse esperando en una dirección mal escrita.
*/
import pg from 'pg';
const argumentos = process.argv.slice(2);
const quitar = argumentos.includes('--quitar');
const email = argumentos
.find((a) => !a.startsWith('--'))
?.trim()
.toLowerCase();
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 15_000 });
await c.connect();
if (!email) {
const { rows } = await c.query(
`select email, ultimo_acceso_en from usuario where rol = 'admin' order by email`
);
if (rows.length === 0) {
console.log('No administra nadie.');
console.log('Para dárselo a alguien: npm run db:admin -- correo@ejemplo.es');
} else {
console.log(`Administran ${rows.length}:`);
for (const f of rows) {
const visto = f.ultimo_acceso_en
? f.ultimo_acceso_en.toISOString().slice(0, 10)
: 'no ha entrado';
console.log(` ${f.email.padEnd(34)} último acceso ${visto}`);
}
}
await c.end();
process.exit(0);
}
const { rows } = await c.query(
`update usuario set rol = $2 where email = $1 returning email, rol`,
[email, quitar ? 'persona' : 'admin']
);
if (rows.length === 0) {
console.log(`No hay ninguna cuenta con «${email}».`);
console.log('Tiene que haber entrado al menos una vez para poder darle el rol.');
await c.end();
process.exit(1);
}
console.log(
quitar ? `${rows[0].email} ya no administra.` : `${rows[0].email} administra: /admin ya le abre.`
);
/*
* Las sesiones abiertas siguen valiendo, y es lo correcto: el rol se lee de la
* base en cada petición —la sesión solo guarda a quién pertenece—, así que dar
* o quitar el permiso surte efecto en la siguiente página que se pida, sin
* tener que echar a nadie fuera.
*/
await c.end();

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { isHttpError, isRedirect } from '@sveltejs/kit';
import { esAdmin, exigirAdmin } from './administracion';
import type { Usuario } from './db/schema';
/** Una cuenta cualquiera, con el rol que se le diga. */
const cuenta = (rol: 'persona' | 'admin'): Usuario => ({
id: 'u1',
email: 'quien@ejemplo.es',
nombre: null,
rol,
creadoEn: new Date(),
ultimoAccesoEn: null
});
const en = (ruta: string) => new URL(ruta, 'https://senzapaura.es');
describe('esAdmin', () => {
it('solo el rol admin administra', () => {
expect(esAdmin(cuenta('admin'))).toBe(true);
expect(esAdmin(cuenta('persona'))).toBe(false);
});
it('sin sesión, no', () => {
expect(esAdmin(null)).toBe(false);
expect(esAdmin(undefined)).toBe(false);
});
});
describe('exigirAdmin', () => {
it('deja pasar a quien administra', () => {
const admin = cuenta('admin');
expect(exigirAdmin({ usuario: admin }, en('/admin'))).toBe(admin);
});
it('sin sesión manda a entrar, y con la vuelta puesta', () => {
// Quien administra también se queda sin sesión de vez en cuando. Volver a
// la portada le obligaría a buscar otra vez dónde estaba.
try {
exigirAdmin({ usuario: null }, en('/admin/glosario?sin=confirmar'));
expect.unreachable('tenía que redirigir');
} catch (salida) {
expect(isRedirect(salida)).toBe(true);
if (!isRedirect(salida)) return;
expect(salida.status).toBe(303);
expect(salida.location).toBe('/entrar?volverA=%2Fadmin%2Fglosario%3Fsin%3Dconfirmar');
}
});
it('con sesión pero sin el rol, 404 y no 403', () => {
// Un 403 confirma que el panel existe y en qué dirección vive. Quien entró
// a comprar una canción no tiene por qué enterarse de eso.
try {
exigirAdmin({ usuario: cuenta('persona') }, en('/admin'));
expect.unreachable('tenía que cortar');
} catch (salida) {
expect(isHttpError(salida)).toBe(true);
if (!isHttpError(salida)) return;
expect(salida.status).toBe(404);
}
});
});

@ -0,0 +1,38 @@
/**
* La puerta del panel.
*
* Una sola función, y se llama desde el `+layout.server.ts` de `/admin`: en
* SvelteKit el layout de una carpeta corre antes que cualquier página de dentro,
* así que la comprobación no se puede olvidar al añadir una pantalla nueva. Una
* guarda que hay que acordarse de copiar en cada ruta acaba faltando en una.
*/
import { error, redirect } from '@sveltejs/kit';
import type { Usuario } from './db/schema';
export function esAdmin(quien: Usuario | null | undefined): boolean {
return quien?.rol === 'admin';
}
/**
* Devuelve la cuenta si administra; si no, no vuelve.
*
* Sin sesión se manda a entrar, con la dirección de vuelta puesta: quien
* administra también se queda sin sesión de vez en cuando, y mandarlo a la
* portada le obligaría a volver a buscar dónde estaba.
*
* Con sesión pero sin el rol, **404 y no 403**. Un 403 confirma que el panel
* existe y en qué dirección vive, que es justo lo que no tiene por qué saber
* quien entró a comprar una canción. La diferencia entre «no puedes» y «no hay
* nada» no le cambia la vida a nadie legítimo y sí a quien va probando.
*/
export function exigirAdmin(locals: App.Locals, url: URL): Usuario {
if (!locals.usuario) {
const volverA = encodeURIComponent(`${url.pathname}${url.search}`);
redirect(303, `/entrar?volverA=${volverA}`);
}
if (!esAdmin(locals.usuario)) error(404, 'No hay nada en esa dirección.');
return locals.usuario;
}

@ -12,11 +12,28 @@ import { ahora, id, instante } from './comun';
* Cuentas
* ---------------------------------------------------------------------- */
/**
* Que puede hacer una cuenta.
*
* Va en la base y no en una variable de entorno con una lista de correos.
* Con la variable, quitarle el acceso a alguien pide un despliegue, y a la
* pregunta «quien administra esto» solo sabe contestar quien pueda leer la
* configuracion del servidor. Aqui se consulta, se audita y se revoca en el
* mismo sitio donde esta todo lo demas.
*
* Dos y no una escala de niveles: hoy administra una persona. El dia que hagan
* falta permisos por seccion, esto se convierte en una tabla; inventarla ahora
* seria mantener un sistema de permisos para un solo usuario.
*/
export const ROLES = ['persona', 'admin'] as const;
export const usuario = pgTable('usuario', {
id: id(),
/** Siempre normalizado en minusculas y sin espacios. */
email: text('email').notNull().unique(),
nombre: text('nombre'),
/** `persona` por defecto: quien se registra no administra nada. */
rol: text('rol', { enum: ROLES }).notNull().default('persona'),
creadoEn: ahora('creado_en'),
ultimoAccesoEn: instante('ultimo_acceso_en')
});

@ -0,0 +1,160 @@
/**
* Lo que sabe la portada del panel.
*
* No es un cuadro de mando: no hay visitas que enseñar ni ventas que celebrar.
* Lo que contesta es «qué falta», que es la pregunta que se hace quien entra
* aquí. Por eso cada sección trae dos números —lo que hay y lo que está a
* medias— y, cuando lo hay, **los nombres**: saber que un género no tiene ficha
* obliga a ir a buscar cuál, y esa búsqueda la puede hacer la consulta.
*/
import { sql } from 'drizzle-orm';
import { db } from './db';
/** Una fila de la portada: una sección del contenido y su estado. */
export interface SeccionDelPanel {
titulo: string;
/** Cuántas cosas hay en total. */
cuantas: number;
/** Cómo se llaman: «géneros», «capítulos». */
unidad: string;
/** Lo que está a medias, si lo hay. Es lo que da trabajo. */
pendiente: {
cuantas: number;
texto: string;
/** Los primeros por nombre; si son más, la fila dice cuántos quedan. */
cuales: string[];
} | null;
/** A dónde lleva, o `null` mientras esa pantalla no exista. */
href: string | null;
}
/** Cuántos nombres se enseñan antes de resumir en «y N más». */
const NOMBRES_A_LA_VISTA = 4;
/**
* Todos los recuentos en una consulta, y todos los nombres en otra.
*
* Dos viajes y no catorce. Ninguna de las subconsultas depende del resultado de
* otra, así que pedirlas por separado solo añadiría idas y venidas para pintar
* una tabla de siete filas.
*/
export async function estadoDelContenido(): Promise<SeccionDelPanel[]> {
const [recuentos, nombres] = await Promise.all([
db.execute<Record<string, number>>(sql`
select
(select count(*) from capitulo where tipo = 'principio')::int as principios,
(select count(*) from capitulo
where tipo = 'principio' and estado <> 'publicado')::int as principios_a_medias,
(select count(*) from capitulo where tipo = 'ficha')::int as fichas,
(select count(*) from capitulo
where tipo = 'ficha' and estado <> 'publicado')::int as fichas_a_medias,
(select count(*) from genero)::int as generos,
(select count(*) from genero g
where not exists (select 1 from capitulo c where c.genero_id = g.id))::int
as generos_sin_ficha,
(select count(*) from termino)::int as terminos,
(select count(*) from capitulo_acepcion where not confirmado)::int as marcas_sin_confirmar,
(select count(*) from cancion where version_de_id is null)::int as canciones,
(select count(*) from cancion
where version_de_id is null and estado <> 'publicado')::int as canciones_a_medias,
(select count(*) from entrada)::int as entradas,
(select count(*) from entrada where estado <> 'publicado')::int as entradas_a_medias,
(select count(*) from persona)::int as personas,
(select count(*) from persona where revisado_en is null and origen = 'importado')::int
as personas_sin_revisar
`),
/*
* Cada rama trae solo las primeras. El `limit` va dentro de cada
* paréntesis y no fuera: puesto al final, cortaría el total y una sección
* con muchas pendientes dejaría a las demás sin ninguna.
*/
db.execute<{ que: string; nombre: string }>(sql`
(select 'principios' as que, titulo as nombre from capitulo
where tipo = 'principio' and estado <> 'publicado'
order by orden limit ${NOMBRES_A_LA_VISTA})
union all
(select 'fichas', titulo from capitulo
where tipo = 'ficha' and estado <> 'publicado'
order by orden limit ${NOMBRES_A_LA_VISTA})
union all
(select 'generos', g.nombre from genero g
where not exists (select 1 from capitulo c where c.genero_id = g.id)
order by g.nombre limit ${NOMBRES_A_LA_VISTA})
union all
(select 'canciones', titulo from cancion
where version_de_id is null and estado <> 'publicado'
order by titulo limit ${NOMBRES_A_LA_VISTA})
union all
(select 'entradas', titulo from entrada
where estado <> 'publicado' order by titulo limit ${NOMBRES_A_LA_VISTA})
union all
(select 'personas', nombre from persona
where revisado_en is null and origen = 'importado'
order by nombre limit ${NOMBRES_A_LA_VISTA})
`)
]);
const n = recuentos.rows[0];
const cualesDe = (que: string) => nombres.rows.filter((f) => f.que === que).map((f) => f.nombre);
/** Lo pendiente solo se cuenta cuando lo hay: un cero no es una tarea. */
const pendiente = (cuantas: number, texto: string, que?: string) =>
cuantas > 0 ? { cuantas, texto, cuales: que ? cualesDe(que) : [] } : null;
return [
{
titulo: 'Los principios',
cuantas: n.principios,
unidad: 'capítulos',
pendiente: pendiente(n.principios_a_medias, 'sin publicar', 'principios'),
href: null
},
{
titulo: 'Fichas de género',
cuantas: n.fichas,
unidad: 'fichas',
pendiente: pendiente(n.fichas_a_medias, 'sin publicar', 'fichas'),
href: null
},
{
titulo: 'Géneros',
cuantas: n.generos,
unidad: 'géneros',
pendiente: pendiente(n.generos_sin_ficha, 'sin ficha', 'generos'),
href: null
},
{
titulo: 'Glosario',
cuantas: n.terminos,
unidad: 'términos',
// Sin nombres: lo pendiente son marcas dentro de un texto, y el nombre
// del término no dice en qué capítulo hay que confirmarlo.
pendiente: pendiente(n.marcas_sin_confirmar, 'marcas por confirmar'),
href: null
},
{
titulo: 'Catálogo',
cuantas: n.canciones,
unidad: 'canciones',
pendiente: pendiente(n.canciones_a_medias, 'sin publicar', 'canciones'),
href: null
},
{
titulo: 'Blog',
cuantas: n.entradas,
unidad: 'entradas',
pendiente: pendiente(n.entradas_a_medias, 'sin publicar', 'entradas'),
href: null
},
{
titulo: 'Personas',
cuantas: n.personas,
unidad: 'fichas',
pendiente: pendiente(n.personas_sin_revisar, 'importadas sin revisar', 'personas'),
href: null
}
];
}

@ -18,15 +18,21 @@
const hayReproduccion = $derived(reproductor.pista !== null);
/*
* La página de espera va desnuda: sin cabecera, sin pie y sin reproductor.
* Mientras la web no está abierta no hay a dónde ir, y un menú cuyos enlaces
* llevan todos al mismo cartel es peor que no tener menú.
* Dos páginas van desnudas: sin cabecera, sin pie y sin reproductor.
*
* La de espera, porque mientras la web no está abierta no hay a dónde ir, y
* un menú cuyos enlaces llevan todos al mismo cartel es peor que no tener
* menú. Y el panel, porque es una herramienta y no una página del sitio: el
* menú de Música y Blog no ayuda a corregir una ficha, y el reproductor
* flotando encima de un formulario tapa el último campo.
*
* Se resuelve aquí y no con un grupo de rutas propio porque eso obligaría a
* mover todas las demás páginas dentro de otra carpeta, y sería un cambio
* enorme para una excepción.
* enorme para dos excepciones.
*/
const desnuda = $derived(page.url.pathname === RUTA_ESPERA);
const desnuda = $derived(
page.url.pathname === RUTA_ESPERA || page.url.pathname.startsWith('/admin')
);
</script>
<svelte:head>

@ -0,0 +1,16 @@
import { exigirAdmin } from '$lib/server/administracion';
import type { LayoutServerLoad } from './$types';
/**
* La única comprobación de permiso de todo el panel.
*
* En SvelteKit el `load` del layout corre antes que el de cualquier página de
* dentro, así que una pantalla nueva queda guardada por el hecho de estar en
* esta carpeta. Repetir la comprobación en cada ruta sería copiarla, y la copia
* que falta es la que se descubre tarde.
*/
export const load: LayoutServerLoad = ({ locals, url }) => {
const admin = exigirAdmin(locals, url);
return { admin: { email: admin.email } };
};

@ -0,0 +1,128 @@
<script lang="ts">
/**
* El panel.
*
* Deliberadamente sobrio y distinto de la web: aquí no se viene a mirar, se
* viene a arreglar algo concreto. Lleva la misma paleta —es el mismo sitio—
* pero sin la cabecera de navegación, sin el reproductor y sin el pie: nada
* de eso ayuda a corregir una ficha, y todo ello ocupa la pantalla.
*
* La barra dice siempre quién eres. En una herramienta que edita lo que se
* publica, saber con qué cuenta estás no es un adorno.
*/
import { resolve } from '$app/paths';
let { data, children } = $props();
</script>
<div class="panel">
<header class="barra">
<a class="marca" href={resolve('/admin')}>
Panel<span class="sitio">Senza Paura</span>
</a>
<div class="quien">
<span class="correo">{data.admin.email}</span>
<a href={resolve('/')}>Ver el sitio</a>
<form method="POST" action={resolve('/salir')}>
<button type="submit">Salir</button>
</form>
</div>
</header>
<main>
{@render children()}
</main>
</div>
<style>
.panel {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--c-bg);
}
.barra {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-5);
flex-wrap: wrap;
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--c-border);
background: var(--c-bg-raised);
}
/*
* «Panel» primero y el nombre del sitio detrás y en pequeño, y no al revés:
* quien está aquí ya sabe de qué web es, lo que necesita saber de un vistazo
* es que está en la herramienta y no en la página.
*/
.marca {
font-family: var(--font-display);
font-size: var(--fs-lg);
text-decoration: none;
color: var(--c-text);
}
.sitio {
margin-left: var(--sp-3);
font-family: var(--font-body);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--c-text-muted);
}
.quien {
display: flex;
align-items: baseline;
gap: var(--sp-4);
font-size: var(--fs-sm);
}
.correo {
color: var(--c-text-muted);
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
.quien form {
display: contents;
}
.quien button {
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.2em;
}
.quien button:hover,
.quien a:hover {
color: var(--c-accent);
}
main {
flex: 1;
width: 100%;
max-width: 72rem;
margin: 0 auto;
padding: var(--sp-6);
}
@media (max-width: 40rem) {
.barra {
padding: var(--sp-4);
}
main {
padding: var(--sp-4);
}
}
</style>

@ -0,0 +1,6 @@
import { estadoDelContenido } from '$lib/server/panel';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return { secciones: await estadoDelContenido() };
};

@ -0,0 +1,176 @@
<script lang="ts">
/**
* La portada del panel: qué hay y qué falta.
*
* Una tabla y no tarjetas. Son siete filas con dos números cada una y la
* pregunta que contestan es comparativa —«¿dónde queda más por hacer?»—; en
* tarjetas, esos números quedan a distinta altura y hay que ir buscándolos.
* Alineados en columna se leen de arriba abajo de una pasada.
*/
import Meta from '$lib/components/Meta.svelte';
let { data } = $props();
const pendientes = $derived(data.secciones.filter((s) => s.pendiente).length);
</script>
<Meta titulo="Panel" descripcion="Administración del contenido." sinIndexar />
<header>
<h1>Panel</h1>
<p class="entradilla">
{#if pendientes === 0}
No hay nada a medias.
{:else}
Hay algo a medias en {pendientes}
{pendientes === 1 ? 'sección' : 'secciones'}.
{/if}
</p>
</header>
<table>
<caption class="visually-hidden">El contenido del sitio, y lo que queda por terminar</caption>
<thead>
<tr>
<th scope="col">Sección</th>
<th scope="col" class="numero">Hay</th>
<th scope="col">Falta</th>
</tr>
</thead>
<tbody>
{#each data.secciones as seccion (seccion.titulo)}
<tr>
<th scope="row">
{#if seccion.href}
<a href={seccion.href}>{seccion.titulo}</a>
{:else}
{seccion.titulo}
{/if}
</th>
<td class="numero">
{seccion.cuantas}
<span class="unidad">{seccion.unidad}</span>
</td>
<td>
{#if seccion.pendiente}
{@const p = seccion.pendiente}
<span class="pendiente">{p.cuantas} {p.texto}</span>
{#if p.cuales.length > 0}
<span class="cuales">
{p.cuales.join(' · ')}{#if p.cuantas > p.cuales.length}, y {p.cuantas -
p.cuales.length} más{/if}
</span>
{/if}
{:else}
<span class="nada" aria-label="nada">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<p class="nota muted">
Las pantallas para editar cada sección todavía no están. Mientras tanto, el contenido se carga con
los scripts de <code>scripts/db</code>.
</p>
<style>
header {
max-width: 62ch;
padding-bottom: var(--sp-5);
}
h1 {
margin: 0 0 var(--sp-2);
font-size: var(--fs-2xl);
}
.entradilla {
margin: 0;
color: var(--c-text-muted);
}
table {
width: 100%;
border-collapse: collapse;
}
caption {
text-align: left;
}
th,
td {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--c-border);
text-align: left;
vertical-align: baseline;
}
thead th {
font-size: var(--fs-xs);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--c-text-muted);
}
tbody th {
font-weight: 500;
}
/*
* Los números a la derecha y con cifras de ancho fijo: así el 6 y el 18 se
* comparan por su sitio, sin leerlos.
*/
.numero {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.unidad {
margin-left: 0.4rem;
font-size: var(--fs-xs);
color: var(--c-text-muted);
}
/* Lo que falta va en el color del dato, que es el del mapa: es un recuento. */
.pendiente {
color: var(--c-dato);
font-variant-numeric: tabular-nums;
}
/*
* Los nombres de lo que falta, detrás del recuento. Son la diferencia entre
* saber que hay trabajo y saber cuál: «1 sin ficha» obliga a ir a buscar
* cuál, y esa búsqueda ya la ha hecho la consulta.
*/
.cuales {
margin-left: 0.6rem;
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.nada {
color: var(--c-text-muted);
}
.nota {
padding-top: var(--sp-5);
font-size: var(--fs-sm);
max-width: 62ch;
}
@media (max-width: 34rem) {
th,
td {
padding: var(--sp-3) var(--sp-2);
}
.unidad {
display: none;
}
}
</style>

@ -1,426 +0,0 @@
# Panel de contenido (Sveltia CMS).
#
# Escribe los mismos archivos Markdown de `src/content/`, así que nada de lo
# que ya funciona cambia: el esquema los sigue validando al compilar, git sigue
# guardando el historial de cada letra y el catálogo sigue sin hacer consultas.
#
# Los campos de aquí replican el esquema de `src/lib/content/schema.ts`. Cuando
# se añada un campo allí, hay que añadirlo también aquí, o el panel lo borrará
# al guardar una entrada.
backend:
name: github
# Cambiar por el repositorio real cuando exista. Trabajando en local no se
# usa: el flujo local abre la carpeta del proyecto desde el propio navegador.
repo: senzapaura/senzapaura
branch: main
# Dónde aterrizan las imágenes que se suban desde el panel. Si prefieres
# agruparlas por disco (static/images/<album>/), se mueven a mano después: al
# esquema le vale cualquier ruta que empiece por "/".
media_folder: static/images
public_folder: /images
collections:
# ---------------------------------------------------------------- estilos
- name: estilos
label: Estilos
label_singular: Estilo
description: Cada estilo es un segmento de la página de música.
folder: src/content/estilos
identifier_field: nombre
create: true
slug: '{{slug}}'
summary: '{{nombre}}'
fields:
- { name: nombre, label: Nombre, widget: string }
- name: resumen
label: Resumen
widget: string
required: false
hint: Una frase corta; acompaña al título del segmento.
- name: orden
label: Orden
widget: number
value_type: int
min: 0
default: 10
hint: Menor número, más arriba en la página de música.
- name: principal
label: Estilo principal
widget: boolean
default: false
required: false
hint: Solo uno puede serlo. Es el que abre la portada.
- { name: imagen, label: Imagen del segmento, widget: image, required: false }
- name: body
label: Texto introductorio
widget: markdown
required: false
# ---------------------------------------------------------------- álbumes
- name: albumes
label: Álbumes
label_singular: Álbum
description: Discos, EPs, singles y colecciones.
folder: src/content/albumes
identifier_field: titulo
create: true
slug: '{{slug}}'
summary: '{{titulo}} · {{fecha}}'
fields:
- { name: titulo, label: Título, widget: string }
- name: estilo
label: Estilo
widget: relation
collection: estilos
value_field: '{{slug}}'
display_fields: [nombre]
search_fields: [nombre]
- { name: fecha, label: Fecha de publicación, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, picker_utc: true }
- name: tipo
label: Tipo
widget: select
default: album
options: [album, ep, single, coleccion]
- { name: portada, label: Portada, widget: image, required: false }
- { name: sello, label: Sello, widget: string, required: false }
- { name: resumen, label: Resumen, widget: string, required: false }
- { name: destacado, label: Destacado, widget: boolean, default: false, required: false }
- name: edicion
label: Datos de edición
widget: object
required: false
collapsed: true
fields:
- { name: numeroCatalogo, label: Nº de catálogo, widget: string, required: false }
- name: formatos
label: Formatos
widget: select
multiple: true
required: false
options: [digital, cd, vinilo, casete]
- { name: pais, label: País, widget: string, required: false }
- name: creditos
label: Créditos del disco
widget: list
required: false
label_singular: Crédito
summary: '{{rol}}: {{nombre}}'
fields:
- { name: rol, label: Cometido, widget: string }
- { name: nombre, label: Nombre, widget: string }
- name: enlaces
label: Enlaces de escucha
widget: object
required: false
collapsed: true
fields:
- { name: spotify, label: Spotify, widget: string, required: false }
- { name: appleMusic, label: Apple Music, widget: string, required: false }
- { name: youtube, label: YouTube, widget: string, required: false }
- { name: bandcamp, label: Bandcamp, widget: string, required: false }
- name: body
label: Notas del disco
widget: markdown
required: false
# -------------------------------------------------------------- canciones
- name: canciones
label: Canciones
label_singular: Canción
description: El cuerpo del texto es la letra. Los saltos de línea se respetan.
folder: src/content/canciones
identifier_field: titulo
create: true
slug: '{{slug}}'
summary: '{{titulo}}'
fields:
- { name: titulo, label: Título, widget: string }
- name: estilo
label: Estilo
widget: relation
collection: estilos
value_field: '{{slug}}'
display_fields: [nombre]
search_fields: [nombre]
- name: album
label: Álbum
widget: relation
collection: albumes
value_field: '{{slug}}'
display_fields: [titulo]
search_fields: [titulo]
required: false
hint: Déjalo vacío si es un single suelto; irá al álbum «Singles» de su estilo.
- name: numero
label: Nº de pista
widget: number
value_type: int
min: 1
required: false
hint: Obligatorio si hay álbum. Se ignora en los singles.
- name: duracion
label: Duración
widget: string
required: false
pattern: ['^\d{1,2}:[0-5]\d$', 'Escríbela como m:ss, por ejemplo 4:12']
- { name: ano, label: Año, widget: number, value_type: int, min: 1900, max: 2200, required: false }
- name: audio
label: Archivo de audio
widget: file
required: false
media_folder: /media/audio/subidas
public_folder: subidas
hint: >-
Sube aquí el archivo completo, con el nombre que sea. Al arrancar
(`npm run dev`) se coloca en la carpeta de su disco, se genera el
fragmento de muestra y se rellena la duración si está vacía.
- { name: portada, label: Portada propia, widget: image, required: false, hint: 'Si la dejas vacía se usa la del álbum.' }
- name: interpretes
label: Intérpretes
widget: list
required: false
field: { name: nombre, label: Nombre, widget: string }
- name: letra
label: Firma de la letra
widget: list
required: false
hint: Déjalo vacío si la firmas tú.
field: { name: nombre, label: Nombre, widget: string }
- name: musica
label: Firma de la música
widget: list
required: false
hint: Déjalo vacío si la firmas tú.
field: { name: nombre, label: Nombre, widget: string }
- { name: destacada, label: Destacada, widget: boolean, default: false, required: false }
- name: instrumental
label: Instrumental
widget: boolean
default: false
required: false
hint: Sin letra. El cuerpo pasa a leerse como notas de la pieza.
- { name: alaVenta, label: A la venta, widget: boolean, required: false, hint: 'Por defecto se vende si tiene audio.' }
- name: idioma
label: Idioma de la letra
widget: select
default: es
options:
- { label: 'Español', value: es }
- { label: 'Inglés', value: en }
- { label: 'Portugués', value: pt }
- { label: 'Francés', value: fr }
- { label: 'Italiano', value: it }
- { label: 'Catalán', value: ca }
- { label: 'Gallego', value: gl }
- { label: 'Euskera', value: eu }
required: false
- name: traduccion
label: Traducción al español
widget: text
required: false
hint: >-
Solo si la letra no está en español. Se enseña en una segunda columna
al lado de la original, así que conviene respetar el corte de los
versos para que se puedan seguir en paralelo.
- name: inspiracion
label: De dónde sale
widget: text
required: false
hint: Separa los párrafos con una línea en blanco.
- name: etiquetas
label: Etiquetas
widget: list
required: false
hint: >-
De qué habla, cómo suena, con quién se canta. Cruzan el catálogo por
donde no lo hacen el estilo ni el disco.
- name: versiones
label: Alternativas musicales
widget: list
required: false
label_singular: Versión
summary: '{{nombre}}'
collapsed: true
fields:
- { name: nombre, label: Nombre del arreglo, widget: string }
- name: audio
label: Archivo de audio
widget: file
media_folder: /media/audio/subidas
public_folder: subidas
hint: El arreglo completo. Se coloca solo al arrancar, como el principal.
- name: duracion
label: Duración
widget: string
required: false
pattern: ['^\d{1,2}:[0-5]\d$', 'Escríbela como m:ss']
- { name: nota, label: Nota, widget: string, required: false }
- name: videos
label: Vídeos
widget: list
required: false
label_singular: Vídeo
summary: '{{titulo}}'
collapsed: true
fields:
- name: youtube
label: Enlace o id de YouTube
widget: string
hint: Pega el enlace tal cual; el esquema saca el id.
- { name: titulo, label: Qué es, widget: string, required: false }
- name: registro
label: Registro de la obra
widget: object
required: false
collapsed: true
fields:
- name: iswc
label: ISWC
widget: string
required: false
pattern: ['^[Tt][-\s.]?\d{3}[.\s]?\d{3}[.\s]?\d{3}[-\s]?\d$', 'Formato T-DDD.DDD.DDD-C']
hint: Identifica la OBRA. Es el mismo en todas sus versiones.
- name: isrc
label: ISRC
widget: string
required: false
pattern: ['^[A-Za-z]{2}[-\s]?[A-Za-z0-9]{3}[-\s]?\d{2}[-\s]?\d{5}$', 'Formato CC-XXX-YY-NNNNN']
hint: Identifica ESTA grabación. Cada versión tiene el suyo.
- { name: entidad, label: Entidad de gestión, widget: string, required: false }
- { name: numero, label: Nº de registro, widget: string, required: false }
- { name: editorial, label: Editorial musical, widget: string, required: false }
- name: reparto
label: Reparto de autoría
widget: list
required: false
label_singular: Parte
summary: '{{nombre}} — {{porcentaje}} %'
hint: Si lo rellenas, los porcentajes tienen que sumar exactamente 100.
fields:
- { name: nombre, label: Nombre, widget: string }
- name: rol
label: Qué firma
widget: select
default: letra y musica
options: ['letra', 'musica', 'letra y musica']
- { name: porcentaje, label: Porcentaje, widget: number, value_type: float, min: 0.01, max: 100 }
- name: enlaces
label: Enlaces de escucha
widget: object
required: false
collapsed: true
fields:
- { name: spotify, label: Spotify, widget: string, required: false }
- { name: appleMusic, label: Apple Music, widget: string, required: false }
- { name: youtube, label: YouTube, widget: string, required: false }
- name: body
label: Letra
widget: markdown
required: false
hint: Los saltos de línea se respetan; cada verso en su línea.
# ----------------------------------------------------------------- taller
- name: taller
label: Taller de letras
label_singular: Capítulo
description: Los principios del oficio y una ficha de creación por estilo.
folder: src/content/taller
identifier_field: titulo
create: true
slug: '{{slug}}'
summary: '{{titulo}}'
fields:
- { name: titulo, label: Título, widget: string }
- name: tipo
label: Tipo
widget: select
default: principio
options:
- { label: 'Principio (el curso)', value: principio }
- { label: 'Ficha de estilo', value: ficha }
- { name: orden, label: Orden dentro de su bloque, widget: number, value_type: int, min: 0, default: 10 }
- { name: resumen, label: Resumen, widget: string, required: false }
- name: ficha
label: Datos del estilo
widget: object
required: false
collapsed: true
hint: Solo para las fichas de estilo; en los principios se deja vacío.
fields:
- { name: origen, label: Origen, widget: string, required: false }
- { name: compas, label: Compás, widget: string, required: false }
- { name: tempo, label: Tempo, widget: string, required: false }
- { name: metrica, label: Métrica, widget: string, required: false }
- { name: estructura, label: Estructura, widget: string, required: false }
- { name: rima, label: Rima, widget: string, required: false }
- name: temas
label: De qué se habla
widget: list
required: false
field: { name: tema, label: Tema, widget: string }
- name: rasgos
label: Rasgos de escritura
widget: list
required: false
field: { name: rasgo, label: Rasgo, widget: string }
- { name: body, label: Texto del capítulo, widget: markdown }
# ---------------------------------------------------------------- páginas
# ------------------------------------------------------------------- blog
- name: blog
label: Blog
label_singular: Entrada
description: Apuntes fechados. El cuerpo del texto es el artículo.
folder: src/content/blog
identifier_field: titulo
create: true
slug: '{{slug}}'
summary: '{{titulo}} · {{fecha}}'
fields:
- { name: titulo, label: Título, widget: string }
- { name: fecha, label: Fecha, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, picker_utc: true }
- name: resumen
label: Resumen
widget: string
required: false
hint: Una frase; es lo que se lee en el listado y en los buscadores.
- { name: imagen, label: Imagen de cabecera, widget: image, required: false }
- name: etiquetas
label: Etiquetas
widget: list
required: false
hint: Las mismas que las canciones; así el blog cruza con el catálogo.
- name: canciones
label: Temas de los que habla
widget: relation
collection: canciones
value_field: '{{slug}}'
display_fields: [titulo]
search_fields: [titulo]
multiple: true
required: false
- name: borrador
label: Borrador
widget: boolean
default: false
required: false
hint: Mientras esté marcado no aparece en la web.
- name: body
label: Texto
widget: markdown
- name: paginas
label: Páginas
label_singular: Página
description: Textos sueltos, como la biografía.
folder: src/content/paginas
identifier_field: titulo
create: true
slug: '{{slug}}'
summary: '{{titulo}}'
fields:
- { name: titulo, label: Título, widget: string }
- { name: descripcion, label: Descripción, widget: string, required: false }
- { name: body, label: Texto, widget: markdown }

@ -1,20 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- El panel no es contenido del sitio: que no acabe en los buscadores. -->
<meta name="robots" content="noindex, nofollow" />
<title>Panel de contenido · Senza Paura</title>
</head>
<body>
<!--
Sveltia CMS. Es un archivo suelto que se carga aquí y lee `config.yml`;
no entra en el bundle de la aplicación ni la toca en absoluto. Escribe
los mismos Markdown de `src/content/`, así que todo lo que ya hay sigue
en pie: la validación al compilar, el historial en git y que el catálogo
no haga ni una consulta.
-->
<script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>
</body>
</html>
Loading…
Cancel
Save

Powered by TurnKey Linux.