feat(seo): per-aviso OG images generated at build
ci / build-and-test (push) Canceled after 0s

scripts/build-og-image.ts (renamed from .mjs, run via tsx) now also
queries getAvisosAmbientales and rasterizes a 1200x630 PNG per aviso at
public/og/avis/[origen]-[id].png. The SVG is built inline from the aviso
fields: title (truncated to 110 chars), summary (200), source and
municipio as meta line. [slug].astro passes /og/avis/<slug>.png + the
title as alt to BaseLayout, which emits og:image, og:image:alt,
twitter:image and twitter:image:alt automatically.

e2e: detail page test now also asserts og:image points at the per-aviso
PNG and that the PNG returns 200. 20/20 e2e pass; check and eslint
clean.
This commit is contained in:
2026-07-26 22:15:52 +02:00
parent 743bbbb495
commit 0abbd77828
12 changed files with 120 additions and 44 deletions
+36 -24
View File
@@ -27,6 +27,7 @@
"eslint": "^9.39.5",
"eslint-plugin-astro": "^3.0.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"tsx": "^4.23.1",
"typescript": "^5.6.3",
"typescript-eslint": "^8.65.0"
},
@@ -932,18 +933,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -954,17 +943,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -8271,6 +8249,40 @@
"license": "0BSD",
"optional": true
},
"node_modules/tsx": {
"version": "4.23.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -9331,7 +9343,7 @@
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"devOptional": true,
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
+2 -1
View File
@@ -8,7 +8,7 @@
},
"scripts": {
"dev": "astro dev",
"prebuild": "node scripts/build-og-image.mjs && astro check && eslint .",
"prebuild": "tsx scripts/build-og-image.ts && astro check && eslint .",
"build": "astro build",
"preview": "astro preview",
"check": "astro check",
@@ -39,6 +39,7 @@
"eslint": "^9.39.5",
"eslint-plugin-astro": "^3.0.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"tsx": "^4.23.1",
"typescript": "^5.6.3",
"typescript-eslint": "^8.65.0"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

-19
View File
@@ -1,19 +0,0 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..');
const svgPath = resolve(repoRoot, 'public/og-default.svg');
const pngPath = resolve(repoRoot, 'public/og-default.png');
const svg = await readFile(svgPath);
const png = await sharp(svg, { density: 192 })
.resize({ width: 1200, height: 630, fit: 'contain', background: { r: 239, g: 234, b: 224, alpha: 1 } })
.png({ compressionLevel: 9 })
.toBuffer();
await mkdir(dirname(pngPath), { recursive: true });
await writeFile(pngPath, png);
console.log(`generated ${pngPath} (${png.length} bytes)`);
+74
View File
@@ -0,0 +1,74 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import { getAvisosAmbientales } from '../src/lib/db';
import type { Aviso } from '../src/lib/types';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..');
const publicDir = resolve(repoRoot, 'public');
const defaultSvgPath = resolve(publicDir, 'og-default.svg');
const defaultPngPath = resolve(publicDir, 'og-default.png');
const perAvisoDir = resolve(publicDir, 'og/avis');
const escapeXml = (s: string): string =>
s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
const fitText = (text: string, maxChars: number): string =>
text.length <= maxChars ? text : `${text.slice(0, maxChars - 1).trimEnd()}`;
const renderAvisoSvg = (aviso: Aviso): string => {
const titulo = fitText(escapeXml(aviso.titulo), 110);
const resumen = fitText(escapeXml(aviso.resumen), 200);
const meta = [aviso.fuente, aviso.municipio]
.filter((s): s is string => Boolean(s))
.map(escapeXml)
.join(' · ');
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<title>${escapeXml(aviso.titulo)}</title>
<rect width="1200" height="630" fill="#efeae0"/>
<rect x="54" y="54" width="1092" height="522" fill="#f8f5ee" stroke="#1f2a24" stroke-width="3"/>
<path d="M118 190c54-132 118-184 300-212-91 61-137 136-164 267-45-15-91-26-136-55z" fill="#2f6e51" transform="translate(0 96)"/>
<text x="118" y="354" fill="#1f2a24" font-family="Georgia, serif" font-size="56" font-weight="700">${titulo}</text>
<line x1="118" y1="378" x2="1082" y2="378" stroke="#d7cfbd" stroke-width="2"/>
<text x="118" y="428" fill="#4a5751" font-family="Georgia, serif" font-size="26">${resumen}</text>
<line x1="118" y1="528" x2="1082" y2="528" stroke="#d7cfbd" stroke-width="2"/>
<text x="118" y="556" fill="#1d4a37" font-family="Arial, sans-serif" font-size="20" font-weight="700">${meta}</text>
<text x="1082" y="556" text-anchor="end" fill="#4a5751" font-family="Arial, sans-serif" font-size="20">Ibiza Environmental Watch</text>
</svg>
`;
};
const rasterizeSvg = async (svg: string, outPath: string): Promise<number> => {
const png = await sharp(Buffer.from(svg), { density: 192 })
.resize({ width: 1200, height: 630, fit: 'contain', background: { r: 239, g: 234, b: 224, alpha: 1 } })
.png({ compressionLevel: 9 })
.toBuffer();
await writeFile(outPath, png);
return png.length;
};
const defaultSvg = await readFile(defaultSvgPath);
await mkdir(dirname(defaultPngPath), { recursive: true });
const defaultBytes = await sharp(defaultSvg, { density: 192 })
.resize({ width: 1200, height: 630, fit: 'contain', background: { r: 239, g: 234, b: 224, alpha: 1 } })
.png({ compressionLevel: 9 })
.toBuffer();
await writeFile(defaultPngPath, defaultBytes);
console.log(`generated ${defaultPngPath} (${defaultBytes.length} bytes)`);
const avisos = await getAvisosAmbientales();
await mkdir(perAvisoDir, { recursive: true });
for (const aviso of avisos) {
const slug = `${aviso.origen}-${aviso.id}`;
const out = join(perAvisoDir, `${slug}.png`);
const bytes = await rasterizeSvg(renderAvisoSvg(aviso), out);
console.log(`generated ${out} (${bytes} bytes)`);
}
+2
View File
@@ -47,6 +47,8 @@ const articleSchema = {
<BaseLayout
title={`${aviso.titulo} | Ibiza Environmental Watch`}
description={aviso.resumen}
ogImage={`/og/avis/${slug}.png`}
ogImageAlt={aviso.titulo}
ogType="article"
pageType="WebPage"
structuredData={articleSchema}
+6
View File
@@ -111,6 +111,12 @@ test.describe('site smoke', () => {
expect(article?.['isAccessibleForFree']).toBe(true);
expect(article?.['inLanguage']).toBe('ca-ES');
expect(article?.['mainEntityOfPage']).toBeTruthy();
const ogImage = await page.locator('meta[property="og:image"]').first().getAttribute('content');
expect(ogImage).toMatch(/\/og\/avis\/[a-z0-9-]+\.png$/);
const ogUrl = ogImage!.replace('https://www.eivissaenvironmentalwatch.es', '');
const ogRes = await request.get(ogUrl);
expect(ogRes.status(), ogUrl).toBe(200);
});
test('sitemap includes every aviso detail page', async ({ request }) => {