feat(seo): custom news sitemap with per-aviso news blocks
ci / build-and-test (push) Canceled after 0s

Replace the @astrojs/sitemap integration with two handwritten endpoints
under src/pages/ so we can attach a Google News <news:news> block to each
aviso detail URL. The integration already emitted the news namespace
but never the per-URL blocks, so the project was not eligible for
Google News indexing.

- src/pages/sitemap-index.xml.ts: single-child sitemapindex pointing at
  /sitemap-0.xml.
- src/pages/sitemap-0.xml.ts: home + huella + one entry per aviso from
  getAvisosAmbientales. Each aviso carries <news:news> with
  publication name (fuente), language ca, publication_date (ISO),
  title and keywords (temas joined).
- astro.config.mjs: drop the @astrojs/sitemap integration; remove the
  dependency from package.json and pnpm-lock.yaml.
- e2e: add a test asserting the news namespace and at least one
  <news:news> block with publication_date and language. 22/22 e2e
  pass; check and eslint clean.
This commit is contained in:
2026-07-26 22:27:32 +02:00
parent f61cde82f9
commit a06316dade
6 changed files with 81 additions and 56 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';
import sitemap from '@astrojs/sitemap';
// Salida estática pura: HTML/JSON servidos por Caddy, sin proceso Node en
// producción. Los datos se consultan a Postgres una única vez, en build-time
@@ -8,7 +7,7 @@ import sitemap from '@astrojs/sitemap';
export default defineConfig({
output: 'static',
site: 'https://www.eivissaenvironmentalwatch.es',
integrations: [preact(), sitemap()],
integrations: [preact()],
build: {
inlineStylesheets: 'auto', // inline CSS pequeños, externos los grandes (ej. Leaflet)
},
-1
View File
@@ -23,7 +23,6 @@
"dependencies": {
"@astrojs/preact": "^6.0.1",
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.3",
"astro": "^7.1.3",
"leaflet": "^1.9.4",
"pg": "^8.13.0",
-53
View File
@@ -14,9 +14,6 @@ importers:
'@astrojs/rss':
specifier: ^4.0.19
version: 4.0.19
'@astrojs/sitemap':
specifier: ^3.7.3
version: 3.7.3
astro:
specifier: ^7.1.3
version: 7.1.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(rollup@4.62.2)(tsx@4.23.1)(yaml@2.9.0)
@@ -180,9 +177,6 @@ packages:
'@astrojs/rss@4.0.19':
resolution: {integrity: sha512-e+z5wYeYtffQdHQO8c2tkSd2JEBdAuRXJV4ZEU5IxkYeE6e39woDd7nw1PH1Kk2tEYNCYuKdylnnbhGmt61awA==}
'@astrojs/sitemap@3.7.3':
resolution: {integrity: sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==}
'@astrojs/telemetry@3.3.3':
resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==}
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
@@ -1293,18 +1287,12 @@ packages:
'@types/nlcst@2.0.3':
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -1444,9 +1432,6 @@ packages:
anynum@1.0.1:
resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -2824,11 +2809,6 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
sitemap@9.0.1:
resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==}
engines: {node: '>=20.19.5', npm: '>=10.8.2'}
hasBin: true
smol-toml@1.7.0:
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
engines: {node: '>= 18'}
@@ -2856,9 +2836,6 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
stream-replace-string@2.0.0:
resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -2988,9 +2965,6 @@ packages:
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
@@ -3498,12 +3472,6 @@ snapshots:
piccolore: 0.1.3
zod: 4.4.3
'@astrojs/sitemap@3.7.3':
dependencies:
sitemap: 9.0.1
stream-replace-string: 2.0.0
zod: 4.4.3
'@astrojs/telemetry@3.3.3':
dependencies:
ci-info: 4.4.0
@@ -4385,10 +4353,6 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/node@24.13.3':
dependencies:
undici-types: 7.18.2
'@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
@@ -4399,10 +4363,6 @@ snapshots:
pg-protocol: 1.15.0
pg-types: 2.2.0
'@types/sax@1.2.7':
dependencies:
'@types/node': 26.1.1
'@types/unist@3.0.3': {}
'@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
@@ -4593,8 +4553,6 @@ snapshots:
anynum@1.0.1: {}
arg@5.0.2: {}
argparse@2.0.1: {}
aria-query@5.3.2: {}
@@ -6289,13 +6247,6 @@ snapshots:
sisteransi@1.0.5: {}
sitemap@9.0.1:
dependencies:
'@types/node': 24.13.3
'@types/sax': 1.2.7
arg: 5.0.2
sax: 1.6.0
smol-toml@1.7.0: {}
source-map-js@1.2.1: {}
@@ -6313,8 +6264,6 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
stream-replace-string@2.0.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -6477,8 +6426,6 @@ snapshots:
uncrypto@0.1.3: {}
undici-types@7.18.2: {}
undici-types@8.3.0: {}
unified@11.0.5:
+55
View File
@@ -0,0 +1,55 @@
import type { APIContext } from 'astro';
import { getAvisosAmbientales } from '../lib/db';
const escapeXml = (s: string): string =>
s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
const STATIC_PATHS = ['/', '/huella/'] as const;
export async function GET(context: APIContext) {
const site = (context.site ?? new URL(context.url.origin)).toString().replace(/\/$/, '');
const avisos = await getAvisosAmbientales();
const staticUrls = STATIC_PATHS.map(
(p) => ` <url>
<loc>${site}${p}</loc>
</url>`
).join('\n');
const avisoUrls = avisos
.map((aviso) => {
const path = `/avis/${aviso.origen}-${aviso.id}/`;
const title = escapeXml(aviso.titulo);
const keywords = escapeXml(aviso.temas.join(', '));
const pubDate = new Date(aviso.fecha).toISOString();
return ` <url>
<loc>${site}${path}</loc>
<news:news>
<news:publication>
<news:name>${escapeXml(aviso.fuente)}</news:name>
<news:language>ca</news:language>
</news:publication>
<news:publication_date>${pubDate}</news:publication_date>
<news:title>${title}</news:title>
<news:keywords>${keywords}</news:keywords>
</news:news>
</url>`;
})
.join('\n');
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
${staticUrls}
${avisoUrls}
</urlset>
`;
return new Response(body, {
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
});
}
+15
View File
@@ -0,0 +1,15 @@
import type { APIContext } from 'astro';
export function GET(context: APIContext) {
const site = (context.site ?? new URL(context.url.origin)).toString().replace(/\/$/, '');
const body = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>${site}/sitemap-0.xml</loc>
</sitemap>
</sitemapindex>
`;
return new Response(body, {
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
});
}
+10
View File
@@ -129,6 +129,16 @@ test.describe('site smoke', () => {
expect(r.status(), p).toBe(200);
}
});
test('news sitemap declares Google News namespace and per-aviso news block', async ({ request }) => {
const res = await request.get('/sitemap-0.xml');
const sitemap = await res.text();
expect(sitemap).toContain('xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"');
const newsBlocks = sitemap.match(/<news:news>/g) ?? [];
expect(newsBlocks.length).toBeGreaterThan(0);
expect(sitemap).toMatch(/<news:publication_date>[^<]+<\/news:publication_date>/);
expect(sitemap).toMatch(/<news:language>ca<\/news:language>/);
});
});
test.describe('build artifacts', () => {