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:
Generated
+36
-24
@@ -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
@@ -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 |
@@ -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)`);
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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)`);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user