feat: per-aviso detail pages with NewsArticle JSON-LD
ci / build-and-test (push) Canceled after 0s

Generate a static page per aviso at /avis/[origen]-[id] using getStaticPaths
from getAvisosAmbientales. Each page carries a NewsArticle JSON-LD node
(headline, description, datePublished, inLanguage, isAccessibleForFree,
keywords, author, publisher, mainEntityOfPage) and renders the title,
metadata, temas, summary and a 'font original' aside pointing at the
external source (rel=noopener noreferrer, target=_blank, with a
visually-hidden SR cue). EntryCard keeps the external link as the primary
action and gains an inline 'Llegir més' link to the internal detail.

e2e coverage:
- detail page renders H1 and external link
- JSON-LD parses and includes Organization, WebSite and NewsArticle with
  the expected metadata
- every URL listed in sitemap-0.xml under /avis/ returns 200

Sitemap now lists the 6 fixture avisos as canonical URLs. JSON-LD
selectors are scoped to head to avoid inline scripts in the body.
20/20 e2e pass; astro check and eslint clean.
This commit is contained in:
2026-07-26 22:10:20 +02:00
parent 813246c211
commit 743bbbb495
3 changed files with 201 additions and 2 deletions
+13 -1
View File
@@ -7,6 +7,7 @@ interface Props {
const { aviso } = Astro.props;
const entryId = `aviso-${aviso.origen}-${aviso.id}`;
const titleId = `${entryId}-title`;
const slug = `${aviso.origen}-${aviso.id}`;
const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
day: 'numeric',
@@ -32,7 +33,7 @@ const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
}
<h2 id={titleId} class="entry__title">
<a href={aviso.url} target="_blank" rel="noopener noreferrer">{aviso.titulo}<span class="visually-hidden"> (s'obre en una pestanya nova)</span></a>
<a href={aviso.url} target="_blank" rel="noopener noreferrer">{aviso.titulo}<span class="visually-hidden"> (s'obre a la font original, en una pestanya nova)</span></a>
</h2>
<p class="entry__resumen">{aviso.resumen}</p>
@@ -52,6 +53,10 @@ const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
{aviso.municipio && <span> · {aviso.municipio}</span>}
<span> · <time datetime={aviso.fecha}>{fecha}</time></span>
</p>
<p class="entry__actions">
<a href={`/avis/${slug}/`} class="entry__more" aria-label={`Llegir més sobre: ${aviso.titulo}`}>Llegir més</a>
</p>
</article>
<style>
@@ -124,4 +129,11 @@ const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
font-size: 0.8rem;
color: var(--ink-soft);
}
.entry__actions {
margin: var(--space-1) 0 0;
}
.entry__more {
font-size: 0.85rem;
font-weight: 600;
}
</style>
+150
View File
@@ -0,0 +1,150 @@
---
import type { GetStaticPaths } from 'astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getAvisosAmbientales } from '../../lib/db';
import type { Aviso } from '../../lib/types';
export const getStaticPaths = (async () => {
const avisos = await getAvisosAmbientales();
return avisos.map((aviso) => ({
params: { slug: `${aviso.origen}-${aviso.id}` },
props: { aviso },
}));
}) satisfies GetStaticPaths;
interface Props {
aviso: Aviso;
}
const { aviso } = Astro.props;
const slug = `${aviso.origen}-${aviso.id}`;
const internalUrl = new URL(`/avis/${slug}/`, Astro.site ?? Astro.url.origin).href;
const sourceUrl = aviso.url;
const fecha = new Date(aviso.fecha).toLocaleDateString('ca-ES', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
const articleSchema = {
'@type': 'NewsArticle',
headline: aviso.titulo,
description: aviso.resumen,
url: internalUrl,
mainEntityOfPage: { '@type': 'WebPage', '@id': internalUrl },
datePublished: aviso.fecha,
dateModified: aviso.fecha,
inLanguage: 'ca-ES',
isAccessibleForFree: true,
keywords: aviso.temas.join(', '),
author: { '@type': 'Organization', name: aviso.fuente },
publisher: { '@id': `${(Astro.site ?? new URL(Astro.url.origin)).toString().replace(/\/$/, '')}#organization` },
...(aviso.municipio ? { contentLocation: { '@type': 'Place', name: aviso.municipio } } : {}),
};
---
<BaseLayout
title={`${aviso.titulo} | Ibiza Environmental Watch`}
description={aviso.resumen}
ogType="article"
pageType="WebPage"
structuredData={articleSchema}
>
<article class="aviso" aria-labelledby="aviso-title">
<p class="aviso__crumbs">
<a href="/">← Torna als avisos</a>
</p>
<header class="aviso__header">
<p class="aviso__meta">
<span>{aviso.fuente}</span>
{aviso.municipio && <span> · {aviso.municipio}</span>}
<span> · <time datetime={aviso.fecha}>{fecha}</time></span>
{aviso.numeroEdicte && <span> · exp. {aviso.numeroEdicte}</span>}
</p>
<h1 id="aviso-title" class="aviso__title">{aviso.titulo}</h1>
{
aviso.temas.length > 0 && (
<ul class="aviso__temas" aria-label="Temes de l'avís">
{aviso.temas.map((t) => <li>{t}</li>)}
</ul>
)
}
</header>
<section class="aviso__body" aria-label="Resum de l'avís">
<p>{aviso.resumen}</p>
</section>
<aside class="aviso__source" aria-label="Font original">
<p>
Text complet disponible a la font:
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">
{aviso.fuente}
<span class="visually-hidden"> (s'obre a la font original, en una pestanya nova)</span>
</a>
</p>
</aside>
</article>
</BaseLayout>
<style>
.aviso {
max-width: 42rem;
margin: 0 auto;
padding: var(--space-3) var(--space-2) var(--space-4);
}
.aviso__crumbs {
margin: 0 0 var(--space-2);
font-size: 0.85rem;
}
.aviso__header {
margin-bottom: var(--space-2);
}
.aviso__title {
font-family: var(--font-display);
font-size: clamp(1.5rem, 3vw, 2rem);
line-height: 1.25;
margin: var(--space-1) 0;
color: var(--ink);
}
.aviso__meta {
margin: 0;
color: var(--ink-soft);
font-size: 0.85rem;
}
.aviso__temas {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
padding: 0;
margin: var(--space-1) 0 0;
}
.aviso__temas li {
font-family: var(--font-mono);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--posidonia-deep);
background: var(--posidonia-pale);
padding: 0.15rem 0.5rem;
border-radius: 999px;
}
.aviso__body p {
margin: 0 0 var(--space-2);
color: var(--ink);
}
.aviso__source {
margin-top: var(--space-3);
padding: var(--space-2);
border-left: 3px solid var(--posidonia-deep);
background: var(--paper-raised);
color: var(--ink-soft);
font-size: 0.9rem;
}
.aviso__source p {
margin: 0;
}
</style>
+38 -1
View File
@@ -61,7 +61,7 @@ test.describe('site smoke', () => {
test('JSON-LD on the home parses and exposes Organization + ItemList', async ({ page }) => {
await page.goto('/');
const payload = await page.locator('script[type="application/ld+json"]').first().textContent();
const payload = await page.locator('head script[type="application/ld+json"]').first().textContent();
expect(payload).toBeTruthy();
const data = JSON.parse(payload ?? '{}');
const types = (data['@graph'] as Array<{ '@type': string }>).map((n) => n['@type']);
@@ -86,6 +86,43 @@ test.describe('site smoke', () => {
expect(sitemap).toContain('https://www.eivissaenvironmentalwatch.es/huella/');
expect(sitemap).not.toContain('/404/');
});
test('aviso detail page renders and exposes NewsArticle JSON-LD', async ({ request, page }) => {
const index = await request.get('/sitemap-0.xml');
const sitemap = await index.text();
const avisPath = /\/avis\/[a-z0-9-]+\//.exec(sitemap)?.[0];
expect(avisPath, 'sitemap should list at least one aviso detail page').toBeTruthy();
const response = await request.get(avisPath!);
expect(response.status()).toBe(200);
await page.goto(avisPath!);
await expect(page.locator('h1#aviso-title')).toBeVisible();
await expect(page.locator('aside.aviso__source a[rel="noopener noreferrer"][target="_blank"]')).toBeVisible();
const payload = await page.locator('head script[type="application/ld+json"]').first().textContent();
expect(payload).toBeTruthy();
const data = JSON.parse(payload ?? '{}');
const graph = data['@graph'] as Array<Record<string, unknown>>;
const types = graph.map((n) => n['@type']);
expect(types).toEqual(expect.arrayContaining(['Organization', 'WebSite', 'NewsArticle']));
const article = graph.find((n) => n['@type'] === 'NewsArticle');
expect(article, 'expected a NewsArticle node in JSON-LD').toBeTruthy();
expect(article?.['isAccessibleForFree']).toBe(true);
expect(article?.['inLanguage']).toBe('ca-ES');
expect(article?.['mainEntityOfPage']).toBeTruthy();
});
test('sitemap includes every aviso detail page', async ({ request }) => {
const res = await request.get('/sitemap-0.xml');
const sitemap = await res.text();
const avisPaths = sitemap.match(/\/avis\/[a-z0-9-]+\//g) ?? [];
expect(avisPaths.length).toBeGreaterThan(0);
for (const p of avisPaths) {
const r = await request.get(p);
expect(r.status(), p).toBe(200);
}
});
});
test.describe('build artifacts', () => {