import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { RenderedImage, ResvgRenderOptions } from "@resvg/resvg-js";
export interface BookingOgImageInput {
title?: string | null;
description?: string | null;
duration?: number | null;
durations?: number[] | null;
username?: string | null;
ownerEmail?: string | null;
bookingPageTitle?: string | null;
profileImageDataUrl?: string | null;
}
const WIDTH = 1200;
const HEIGHT = 630;
const BRAND_BLUE = "#00B5FF";
const BRAND_MINT = "#48FFE4";
const BG = "#000000";
const SURFACE = "#0a0a0a";
const BORDER = "#1f1f1f";
const FG = "#ededed";
const MUTED = "#a0a0a0";
const FONT_FAMILY = "Liberation Sans, Arial, system-ui, sans-serif";
const FONT_SOURCE_PATHS = [
"../../assets/fonts/LiberationSans-Regular.ttf",
"../../assets/fonts/LiberationSans-Bold.ttf",
] as const;
const AVATAR_CX = 996;
const AVATAR_CY = 170;
const AVATAR_SIZE = 172;
const LOGO_MARK = `
`;
function escapeSvg(value: string): string {
return value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function cleanText(value: string | null | undefined): string {
return String(value ?? "")
.replace(/\s+/g, " ")
.trim();
}
function titleCase(value: string): string {
return value
.split(/[\s._-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(" ");
}
function displayNameFromIdentifier(
username?: string | null,
ownerEmail?: string | null,
): string {
const usernameName = titleCase(cleanText(username));
const emailName = titleCase(cleanText(ownerEmail).split("@")[0]);
if (emailName.split(/\s+/).length > usernameName.split(/\s+/).length) {
return emailName;
}
return usernameName || emailName || "Host";
}
function hostNameFromBookingPageTitle(title?: string | null): string | null {
const clean = cleanText(title);
const match = clean.match(/^book(?:\s+a)?\s+meeting\s+with\s+(.+)$/i);
if (match?.[1]) return cleanText(match[1]);
const meetMatch = clean.match(/^meet\s+(.+)$/i);
if (meetMatch?.[1]) return cleanText(meetMatch[1]);
return null;
}
function isGenericMeetingTitle(title: string): boolean {
return /^(book\s+a\s+meeting|meeting)$/i.test(title);
}
function displayTitle(input: BookingOgImageInput, hostName: string): string {
const title = cleanText(input.title);
const pageTitle = cleanText(input.bookingPageTitle);
if (title && !isGenericMeetingTitle(title)) return title;
return hostName
? `Meet with ${hostName}`
: pageTitle || title || "Book a meeting";
}
function durationLabel(input: BookingOgImageInput): string {
const durations = Array.isArray(input.durations)
? input.durations.filter((value) => Number.isFinite(value) && value > 0)
: [];
if (durations.length > 1) {
return `${durations.slice(0, 3).join(" / ")} min options`;
}
const duration = durations[0] ?? input.duration ?? 30;
return `${duration} min meeting`;
}
function initialsFor(name: string): string {
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length === 0) return "AN";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
function validProfileImageDataUrl(value: string | null | undefined): string {
const dataUrl = cleanText(value);
return /^data:image\/[a-z0-9.+-]+;base64,/i.test(dataUrl) ? dataUrl : "";
}
function estimateTextWidth(value: string, fontSize: number): number {
let units = 0;
for (const char of value) {
if (char === " ") {
units += 0.28;
} else if (/[MW@#%&]/.test(char)) {
units += 0.86;
} else if (/[A-Z]/.test(char)) {
units += 0.64;
} else if (/[ilI.,:;|!']/u.test(char)) {
units += 0.26;
} else if (/[0-9]/.test(char)) {
units += 0.56;
} else {
units += 0.54;
}
}
return units * fontSize;
}
function trimTextToWidth(
value: string,
fontSize: number,
maxWidth: number,
): string {
const ellipsis = "...";
let trimmed = value.trim();
while (
trimmed.length > 0 &&
estimateTextWidth(`${trimmed}${ellipsis}`, fontSize) > maxWidth
) {
trimmed = trimmed.slice(0, -1).trimEnd();
}
return trimmed ? `${trimmed}${ellipsis}` : ellipsis;
}
function wrapText(
value: string,
fontSize: number,
maxWidth: number,
maxLines: number,
): string[] {
const words = value.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let current = "";
for (const word of words) {
const next = current ? `${current} ${word}` : word;
if (estimateTextWidth(next, fontSize) <= maxWidth) {
current = next;
continue;
}
if (!current) {
lines.push(trimTextToWidth(word, fontSize, maxWidth));
current = "";
} else {
lines.push(current);
current = word;
}
if (lines.length === maxLines) break;
}
if (current && lines.length < maxLines) lines.push(current);
const usedWordCount = lines.join(" ").split(/\s+/).filter(Boolean).length;
if (usedWordCount < words.length && lines.length > 0) {
lines[lines.length - 1] = trimTextToWidth(
lines[lines.length - 1],
fontSize,
maxWidth,
);
}
return lines.length ? lines : [trimTextToWidth(value, fontSize, maxWidth)];
}
function textBlock({
lines,
x,
y,
fontSize,
lineHeight,
weight,
fill,
}: {
lines: string[];
x: number;
y: number;
fontSize: number;
lineHeight: number;
weight: number;
fill: string;
}): string {
return `${lines
.map(
(line, index) =>
`${escapeSvg(line)}`,
)
.join("")}`;
}
export function renderBookingOgImageSvg(input: BookingOgImageInput): string {
const inferredHost =
hostNameFromBookingPageTitle(input.bookingPageTitle) ??
displayNameFromIdentifier(input.username, input.ownerEmail);
const title = displayTitle(input, inferredHost);
const titleFitsSingleLine = estimateTextWidth(title, 82) <= 820;
const titleLines = titleFitsSingleLine
? [title]
: wrapText(title, 66, 820, 2);
const duration = durationLabel(input);
const initials = initialsFor(inferredHost);
const profileImageDataUrl = validProfileImageDataUrl(
input.profileImageDataUrl,
);
const titleFontSize = titleFitsSingleLine ? 82 : 66;
const titleLineHeight = titleLines.length > 1 ? 76 : 92;
const titleGroupY = titleLines.length > 1 ? 332 : 382;
const durationBadge =
titleLines.length === 1
? `
${escapeSvg(duration)}
`
: "";
const avatarContent = profileImageDataUrl
? ``
: `
${escapeSvg(initials)}`;
return ``;
}
interface BookingOgRenderOptions {
fontFiles?: string[];
}
function resolveSourceFontFiles(): string[] {
try {
const metaUrl = import.meta.url;
if (!metaUrl.startsWith("file:")) return [];
return FONT_SOURCE_PATHS.map((sourcePath) =>
fileURLToPath(new URL(sourcePath, metaUrl)),
).filter((fontFile) => existsSync(fontFile));
} catch {
return [];
}
}
function bookingOgResvgOptions(
options: BookingOgRenderOptions = {},
): ResvgRenderOptions {
const fontFiles = (
options.fontFiles?.length ? options.fontFiles : resolveSourceFontFiles()
).filter((fontFile) => existsSync(fontFile));
const hasBundledFonts = fontFiles.length > 0;
return {
fitTo: { mode: "width", value: WIDTH },
font: {
loadSystemFonts: !hasBundledFonts,
...(hasBundledFonts ? { fontFiles } : {}),
defaultFontFamily: "Liberation Sans",
sansSerifFamily: "Liberation Sans",
},
};
}
async function loadResvg(): Promise {
return import(/* @vite-ignore */ "@resvg/resvg-js");
}
export async function renderBookingOgImage(
input: BookingOgImageInput,
options: BookingOgRenderOptions = {},
): Promise {
const { Resvg } = await loadResvg();
return new Resvg(renderBookingOgImageSvg(input), {
...bookingOgResvgOptions(options),
}).render();
}
export async function renderBookingOgImagePng(
input: BookingOgImageInput,
options: BookingOgRenderOptions = {},
): Promise {
return (await renderBookingOgImage(input, options)).asPng();
}