feat: add Open Graph image generation and SEO metadata

- Introduced `PageSeo` component for managing page metadata for social sharing.
- Created Open Graph image templates using SVG and static fonts for better visual representation.
- Implemented service alert management with functions to check active alerts and format route numbers.
- Added utility functions for generating and watching alert validity.
- Developed a script to generate Open Graph images dynamically based on page content.
- Included tests for service alert functionalities to ensure correctness and reliability.
This commit is contained in:
2026-10-05 13:02:06 +03:00
parent 6460846134
commit 142ec97959
35 changed files with 8226 additions and 4690 deletions

View File

@@ -0,0 +1,65 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { ImageResponse } from 'next/og.js';
import { ogImageSize, socialLocales, socialPages } from '../lib/social.mjs';
import { createOgImage } from '../lib/og-template.mjs';
const projectRoot = new URL('../', import.meta.url);
const outputDirectory = new URL('public/images/og/', projectRoot);
async function svgAsset(path, color) {
const svg = (await readFile(new URL(path, projectRoot), 'utf8'))
.replaceAll('currentColor', color)
.replace(/<svg\b([^>]*)>/, (tag, attributes) =>
/\sfill=/.test(attributes) ? tag : `<svg${attributes} fill="${color}">`
);
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
}
async function font(name, file, weight) {
return {
name,
data: await readFile(new URL(`assets/fonts/og/${file}`, projectRoot)),
weight,
style: 'normal',
};
}
const fonts = await Promise.all([
font('Manrope', 'Manrope-SemiBold.ttf', 600),
font('Manrope', 'Manrope-ExtraBold.ttf', 800),
font('Caveat', 'Caveat-Bold.ttf', 700),
]);
const [logo, star5, star6, thinStar, shield, note, love, background] =
await Promise.all([
readFile(new URL('assets/images/logo-alt.png', projectRoot)).then(
(data) => `data:image/png;base64,${data.toString('base64')}`
),
svgAsset('assets/images/thick-round-star-5.svg', '#9d7ba0'),
svgAsset('assets/images/thick-round-star-6.svg', '#5780cc'),
svgAsset('assets/images/thin-star-big.svg', '#0058ff'),
svgAsset('assets/icons/shield.svg', '#1d4fac'),
svgAsset('assets/icons/note.svg', '#715573'),
svgAsset('assets/icons/love.svg', '#ff003d'),
svgAsset('assets/images/og-background.svg', '#f7f9fd'),
]);
const assets = { logo, star5, star6, thinStar, shield, note, love, background };
await mkdir(outputDirectory, { recursive: true });
for (const [id, page] of Object.entries(socialPages)) {
for (const locale of socialLocales) {
const response = new ImageResponse(createOgImage(page, locale, assets), {
...ogImageSize,
fonts,
});
const png = Buffer.from(await response.arrayBuffer());
const destination = new URL(`${id}-${locale}.png`, outputDirectory);
const temporary = new URL(`${id}-${locale}.png.tmp`, outputDirectory);
await writeFile(temporary, png);
await rename(temporary, destination);
console.log(
`Generated ${fileURLToPath(destination)} (${png.length} bytes)`
);
}
}