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

179
lib/og-template.mjs Normal file
View File

@@ -0,0 +1,179 @@
import { createElement as h } from 'react';
// ImageResponse uses flex and inline styles, independently of browser CSS,
// animation and the landing's Webpack loaders.
const box = (style, ...children) =>
h('div', { style: { display: 'flex', ...style } }, ...children);
const picture = (src, width, height, style = {}) =>
h('img', { src, width, height, style });
export function createOgImage(page, locale, assets) {
const copy = page[locale];
const handwritten = ['schedule', 'community'].includes(page.illustration);
const privacy = page.illustration === 'privacy';
const accent = page.illustration === 'terms' ? '#715573' : '#1d4fac';
const headingSize = locale === 'ru' && privacy ? 58 : 76;
const secondSize = locale === 'ru' && privacy ? 52 : handwritten ? 100 : 76;
const illustrations = {
schedule: assets.logo,
community: assets.love,
privacy: assets.shield,
terms: assets.note,
};
return box(
{
position: 'relative',
width: '100%',
height: '100%',
padding: 32,
color: '#242424',
backgroundColor: '#fff',
fontFamily: 'Manrope',
fontWeight: 600,
},
box(
{
position: 'relative',
width: '100%',
height: '100%',
borderRadius: 36,
overflow: 'hidden',
backgroundColor: '#f5f7fc',
},
picture(assets.background, 1136, 566, {
position: 'absolute',
left: 0,
top: 0,
}),
picture(assets.star6, 350, 365, {
position: 'absolute',
right: -60,
top: 122,
opacity: 0.72,
transform: 'rotate(16deg)',
}),
picture(assets.star5, 170, 170, {
position: 'absolute',
right: 237,
bottom: 21,
opacity: 0.48,
transform: 'rotate(-15deg)',
}),
picture(assets.thinStar, 148, 148, {
position: 'absolute',
left: -71,
bottom: -60,
opacity: 0.32,
transform: 'rotate(10deg)',
}),
box(
{
position: 'relative',
flexDirection: 'column',
justifyContent: 'space-between',
width: '100%',
height: '100%',
padding: '38px 44px',
},
box(
{ alignItems: 'center', justifyContent: 'space-between' },
box(
{ alignItems: 'center', gap: 12 },
picture(assets.logo, 48, 48),
h('span', { style: { fontSize: 32, fontWeight: 800 } }, 'ridebus')
),
box(
{
padding: '12px 22px',
borderRadius: 40,
fontSize: 18,
color: accent,
backgroundColor: '#ffffffb3',
},
copy.label
)
),
box(
{ flexDirection: 'column', width: 730, marginTop: 10 },
h(
'div',
{
style: {
fontSize: headingSize,
lineHeight: 1.16,
fontWeight: 800,
letterSpacing: '-2.5px',
},
},
copy.heading[0]
),
h(
'div',
{
style: {
fontFamily: handwritten ? 'Caveat' : 'Manrope',
fontSize: secondSize,
fontWeight: handwritten ? 700 : 800,
lineHeight: handwritten ? 1.1 : 1.2,
letterSpacing: handwritten ? '-1px' : '-2px',
color: handwritten ? '#715573' : accent,
marginTop: handwritten ? -2 : 4,
},
},
copy.heading[1]
),
box(
{
marginTop: 22,
maxWidth: 665,
fontSize: 24,
lineHeight: 1.5,
color: '#526079',
},
copy.caption
)
),
box(
{ alignItems: 'center', justifyContent: 'space-between' },
box(
{ gap: 10 },
...copy.tags.map((tag) =>
box(
{
padding: '10px 18px',
borderRadius: 18,
backgroundColor: '#ffffffb3',
fontSize: 17,
color: '#526079',
},
tag
)
)
),
h('span', { style: { fontSize: 20, color: accent } }, 'ridebus.by')
)
),
box(
{
position: 'absolute',
right: 44,
top: 197,
width: 236,
height: 236,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 60,
backgroundColor: '#ffffffdb',
border: '2px solid #ffffff',
transform: 'rotate(-9deg)',
},
picture(
illustrations[page.illustration],
page.illustration === 'schedule' ? 164 : 126,
page.illustration === 'schedule' ? 164 : 126
)
)
)
);
}

View File

@@ -1,3 +1,5 @@
import { getActiveAlerts } from './serviceAlerts.ts';
export type City = {
id: number;
name: string;
@@ -7,9 +9,14 @@ export type City = {
};
export type ServiceAlert = {
id: number;
cityId: number;
type: 'delay' | 'cancellation' | 'detour' | 'info';
title: string;
message: string;
routeNumbers: string[];
routeIds: number[];
isCityWide: boolean;
routeId: number | null;
routeNumber: string | null;
routeName: string | null;
startTime: string;
@@ -119,7 +126,7 @@ function cityAvatar(value: unknown, baseUrl: string): string | undefined {
type CacheEntry = { expiresAt: number; data: unknown };
// This module is only imported at runtime by the API route. UI imports are type-only.
// Credentials and the upstream client are only used by the server API route.
export function createRideBusClient(
options: {
baseUrl?: string;
@@ -176,25 +183,54 @@ export function createRideBusClient(
});
}
if (input.resource === 'alerts') {
const cityId = entityId(input.cityId);
const alerts = list(await request('api/v1/alerts/active', input.cityId))
.map(record)
.filter((alert) => alert.is_active !== false)
.filter(
(alert) =>
alert.is_active !== false &&
(alert.city_id == null || entityId(alert.city_id) === cityId)
)
.map((alert): ServiceAlert => {
const startTime = date(alert.start_time);
const type = alert.alert_type;
const routeNumber =
typeof alert.route_number === 'string' && alert.route_number.trim()
? alert.route_number.trim()
: null;
const routeId =
alert.route_id == null ? null : entityId(alert.route_id);
const routeNumbers = [
...new Set(
alert.route_numbers == null
? []
: list(alert.route_numbers).map(text)
),
];
const routeIds = [
...new Set(
alert.route_ids == null ? [] : list(alert.route_ids).map(entityId)
),
];
// Older responses only carry a single route. Check both formats before
// classifying a notice as citywide, including notices with IDs only.
if (!routeNumbers.length && routeNumber)
routeNumbers.push(routeNumber);
if (!routeIds.length && routeId) routeIds.push(routeId);
return {
id: entityId(alert.id),
cityId,
type:
type === 'delay' || type === 'cancellation' || type === 'detour'
? type
: 'info',
title: text(alert.title),
message: text(alert.message),
routeNumber:
typeof alert.route_number === 'string' &&
alert.route_number.trim()
? alert.route_number.trim()
: null,
routeNumbers,
routeIds,
isCityWide: !routeNumbers.length && !routeIds.length,
routeId,
routeNumber,
routeName:
typeof alert.route_name === 'string' && alert.route_name.trim()
? alert.route_name.trim()
@@ -282,14 +318,9 @@ export function createRideBusClient(
const current = (data: unknown) => {
if (input.resource !== 'alerts') return data;
const result = data as Alerts;
const timestamp = now();
return {
...result,
alerts: result.alerts.filter(
(alert) =>
Date.parse(alert.startTime) <= timestamp &&
(!alert.endTime || Date.parse(alert.endTime) >= timestamp)
),
alerts: getActiveAlerts(result.alerts, input.cityId!, now()),
} satisfies Alerts;
};
const key = `${input.resource}:${input.cityId ?? ''}:${input.stopId ?? ''}`;

57
lib/serviceAlerts.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { ServiceAlert } from './ridebus';
export function isAlertActive(alert: ServiceAlert, now: number): boolean {
return (
Date.parse(alert.startTime) <= now &&
(alert.endTime === null || now < Date.parse(alert.endTime))
);
}
export function getActiveAlerts(
alerts: ServiceAlert[],
cityId: number,
now: number
): ServiceAlert[] {
const seen = new Set<number>();
return alerts.filter((alert) => {
if (
alert.cityId !== cityId ||
seen.has(alert.id) ||
!isAlertActive(alert, now)
)
return false;
seen.add(alert.id);
return true;
});
}
export function formatAlertRouteNumbers(alert: ServiceAlert): string {
return alert.routeNumbers.map((number) => `№${number}`).join(' · ');
}
// Recheck the current data at each start/end boundary, independently of polling.
// Long timers are capped because browsers store their delay as a signed int32.
export function watchAlertValidity(
alerts: ServiceAlert[],
onChange: () => void
) {
let timer: ReturnType<typeof setTimeout>;
function schedule() {
const now = Date.now();
const next = alerts
.flatMap((alert) => [alert.startTime, alert.endTime])
.filter((time): time is string => time !== null)
.map(Date.parse)
.filter((time) => time > now)
.reduce((earliest, time) => Math.min(earliest, time), Infinity);
if (Number.isFinite(next))
timer = setTimeout(refresh, Math.min(next - now, 2147483647));
}
function refresh() {
onChange();
schedule();
}
// Also cover a boundary crossed between render and effect subscription.
timer = setTimeout(refresh, 0);
return () => clearTimeout(timer);
}

114
lib/social.mjs Normal file
View File

@@ -0,0 +1,114 @@
import { legalCopy } from '../modules/legal/content.ts';
export const siteUrl = 'https://ridebus.by';
export const socialLocales = ['en', 'ru'];
export const ogImageSize = { width: 1200, height: 630 };
// Shared by the page metadata and the build-time image generator.
export const socialPages = {
home: {
path: '/',
illustration: 'schedule',
en: {
title: 'RideBus',
description:
'RideBus — An app that helps you conveniently view public transportation schedules',
label: 'Public transport, at hand',
heading: ['Public transport', 'made simple'],
caption: 'Schedules, stops and routes. All in one small app.',
tags: ['Schedules', 'Routes', 'Favourites'],
},
ru: {
title: 'RideBus',
description:
'RideBus — Приложение, которое поможет вам удобно смотреть расписание общественного транспорта',
label: 'Транспорт под рукой',
heading: ['Расписание', 'транспорта'],
caption: 'Расписания, остановки и маршруты. В одном приложении.',
tags: ['Расписания', 'Маршруты', 'Избранное'],
},
},
contribute: {
path: '/contribute',
illustration: 'community',
en: {
title: 'RideBus - Contribute',
description:
'Help RideBus grow: add your city, share the app or leave a review.',
label: 'Made with love',
heading: ['Help RideBus', 'grow with you'],
caption: 'Add your city, share the app or leave a review.',
tags: ['Open source', 'Community'],
},
ru: {
title: 'RideBus - Вклад',
description:
'Помогите развитию RideBus: добавьте свой город, расскажите о приложении или оставьте отзыв.',
label: 'Сделано с любовью',
heading: ['Помогите RideBus', 'стать лучше'],
caption:
'Добавьте свой город, расскажите о приложении или оставьте отзыв.',
tags: ['Открытый код', 'Сообщество'],
},
},
privacy: {
path: '/privacy',
illustration: 'privacy',
en: {
title: `${legalCopy.en.documents.privacy.title} — RideBus`,
description: legalCopy.en.documents.privacy.description,
label: legalCopy.en.label,
heading: ['Privacy', 'policy'],
caption: legalCopy.en.documents.privacy.description,
tags: ['Data', 'Permissions'],
},
ru: {
title: `${legalCopy.ru.documents.privacy.title} — RideBus`,
description: legalCopy.ru.documents.privacy.description,
label: legalCopy.ru.label,
heading: ['Политика', 'конфиденциальности'],
caption: legalCopy.ru.documents.privacy.description,
tags: ['Данные', 'Разрешения'],
},
},
terms: {
path: '/terms',
illustration: 'terms',
en: {
title: `${legalCopy.en.documents.terms.title} — RideBus`,
description: legalCopy.en.documents.terms.description,
label: legalCopy.en.label,
heading: ['Terms', 'of use'],
caption: legalCopy.en.documents.terms.description,
tags: ['The service', 'Guidelines'],
},
ru: {
title: `${legalCopy.ru.documents.terms.title} — RideBus`,
description: legalCopy.ru.documents.terms.description,
label: legalCopy.ru.label,
heading: ['Условия', 'использования'],
caption: legalCopy.ru.documents.terms.description,
tags: ['О сервисе', 'Правила'],
},
},
};
/** @param {keyof typeof socialPages} page @param {string} locale */
export function getSocialMetadata(page, locale) {
const language = locale === 'ru' ? 'ru' : 'en';
const definition = socialPages[page];
const localizedPath =
language === 'ru'
? definition.path === '/'
? '/ru'
: `/ru${definition.path}`
: definition.path;
return {
...definition[language],
url: `${siteUrl}${localizedPath}`,
image: `${siteUrl}/images/og/${page}-${language}.png`,
locale: language === 'ru' ? 'ru_RU' : 'en_US',
alternateLocale: language === 'ru' ? 'en_US' : 'ru_RU',
};
}