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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user