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:
179
lib/og-template.mjs
Normal file
179
lib/og-template.mjs
Normal 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
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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
57
lib/serviceAlerts.ts
Normal 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
114
lib/social.mjs
Normal 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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user