[0x0d, 0x11, 0x17], 'panel' => [0x16, 0x1b, 0x22], 'fg' => [0xe6, 0xed, 0xf3], 'muted' => [0x8b, 0x94, 0x9e], 'body' => [0xc9, 0xd1, 0xd9], 'border' => [0x30, 0x36, 0x3d], 'accent' => [0x58, 0xa6, 0xff], ]; /** Dot colours for the language badge (GitHub Linguist-ish). */ const OG_LANG_COLORS = [ 'PHP' => '#4F5D95', 'JavaScript' => '#F1E05A', 'TypeScript' => '#3178C6', 'Python' => '#3572A5', 'Java' => '#B07219', 'CSS' => '#563D7C', 'SCSS' => '#C6538C', 'Sass' => '#A53B70', 'Less' => '#1D365D', 'HTML' => '#E34C26', 'Vue' => '#41B883', 'Svelte' => '#FF3E00', 'JSON' => '#8B949E', 'C' => '#555555', 'C++' => '#F34B7D', 'C#' => '#178600', 'Go' => '#00ADD8', 'Ruby' => '#701516', 'Rust' => '#DEA584', 'Swift' => '#F05138', 'Kotlin' => '#A97BFF', 'SQL' => '#E38C00', 'Shell' => '#89E051', 'XML' => '#0060AC', 'YAML' => '#CB171E', 'TOML' => '#9C4221', ]; // ---- Capability / assets ---------------------------------------------------- /** Absolute path to one of the bundled TrueType faces. */ function og_font(string $style = 'regular'): string { $files = [ 'regular' => 'DejaVuSans.ttf', 'bold' => 'DejaVuSans-Bold.ttf', 'mono' => 'DejaVuSansMono-Bold.ttf', ]; return __DIR__ . '/../assets/fonts/' . ($files[$style] ?? $files['regular']); } /** * True when this server can actually draw a card: GD compiled with FreeType * and PNG support, plus the bundled fonts present. Callers use this to decide * whether to advertise an og:image at all. */ function og_available(): bool { static $ok = null; if ($ok !== null) { return $ok; } if (!function_exists('imagettftext') || !function_exists('imagepng')) { return $ok = false; } foreach (['regular', 'bold', 'mono'] as $style) { if (!is_readable(og_font($style))) { return $ok = false; } } return $ok = true; } // ---- Text measuring / drawing ---------------------------------------------- /** Rendered width of a string, in pixels. */ function og_text_width(string $text, string $font, float $size): int { if ($text === '') { return 0; } $box = imagettfbbox($size, 0, $font, $text); if ($box === false) { return 0; } return (int) ceil(max($box[2], $box[4]) - min($box[0], $box[6])); } /** * Draw a string with its left edge at $x and its baseline at $y. * * @param \GdImage|resource $im * @param int $color */ function og_text($im, string $text, string $font, float $size, int $x, int $y, $color): void { if ($text !== '') { imagettftext($im, $size, 0, $x, $y, $color, $font, $text); } } /** Clip a string to $maxWidth, appending an ellipsis when it does not fit. */ function og_ellipsize(string $text, string $font, float $size, int $maxWidth): string { if (og_text_width($text, $font, $size) <= $maxWidth) { return $text; } $chars = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: []; $out = ''; foreach ($chars as $char) { if (og_text_width($out . $char . '…', $font, $size) > $maxWidth) { break; } $out .= $char; } return rtrim($out) . '…'; } /** * Word-wrap $text to at most $maxLines lines of $maxWidth pixels. Overflow is * folded onto the last line and clipped with an ellipsis, so the card never * silently drops the fact that there was more text. */ function og_wrap(string $text, string $font, float $size, int $maxWidth, int $maxLines): array { $text = trim((string) preg_replace('/\s+/u', ' ', $text)); if ($text === '' || $maxLines < 1) { return []; } $words = explode(' ', $text); $lines = []; $line = ''; foreach ($words as $i => $word) { $try = $line === '' ? $word : $line . ' ' . $word; if ($line !== '' && og_text_width($try, $font, $size) > $maxWidth) { $lines[] = $line; if (count($lines) === $maxLines) { // Out of room: tack the remainder onto the last line so the // ellipsis pass below shows that it was cut short. $lines[$maxLines - 1] .= ' ' . implode(' ', array_slice($words, $i)); break; } $line = $word; } else { $line = $try; } } if (count($lines) < $maxLines && $line !== '') { $lines[] = $line; } // Also catches single words longer than the line box. return array_map( static fn(string $l): string => og_ellipsize($l, $font, $size, $maxWidth), $lines ); } /** * Allocate a colour from an [r, g, b] triple. * * @param \GdImage|resource $im */ function og_color($im, array $rgb) { return imagecolorallocate($im, $rgb[0], $rgb[1], $rgb[2]); } /** * Allocate a colour from a "#rrggbb" string. * * @param \GdImage|resource $im */ function og_hex_color($im, string $hex) { $hex = ltrim($hex, '#'); return imagecolorallocate( $im, (int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2)) ); } /** * Filled rectangle with rounded corners. * * @param \GdImage|resource $im * @param int $color */ function og_rounded_rect($im, int $x1, int $y1, int $x2, int $y2, int $r, $color): void { imagefilledrectangle($im, $x1 + $r, $y1, $x2 - $r, $y2, $color); imagefilledrectangle($im, $x1, $y1 + $r, $x2, $y2 - $r, $color); $d = $r * 2; imagefilledellipse($im, $x1 + $r, $y1 + $r, $d, $d, $color); imagefilledellipse($im, $x2 - $r, $y1 + $r, $d, $d, $color); imagefilledellipse($im, $x1 + $r, $y2 - $r, $d, $d, $color); imagefilledellipse($im, $x2 - $r, $y2 - $r, $d, $d, $color); } // ---- Card rendering --------------------------------------------------------- /** * Draw a card and return the GD image. Takes a plain array so it stays * independent of the database: * * brand string small monospace line at the top * title string repository name (up to 2 lines) * subtitle string e.g. "/my-repo" * description string optional, up to 3 lines * language string optional, shown as a badge * stats array short strings joined with separators along the bottom */ function og_render_card(array $card) { $im = imagecreatetruecolor(OG_WIDTH, OG_HEIGHT); imagealphablending($im, true); $bg = og_color($im, OG_COLORS['bg']); $panel = og_color($im, OG_COLORS['panel']); $fg = og_color($im, OG_COLORS['fg']); $muted = og_color($im, OG_COLORS['muted']); $body = og_color($im, OG_COLORS['body']); $border = og_color($im, OG_COLORS['border']); $accent = og_color($im, OG_COLORS['accent']); imagefilledrectangle($im, 0, 0, OG_WIDTH, OG_HEIGHT, $bg); imagefilledrectangle($im, 0, 0, OG_WIDTH, 11, $accent); $regular = og_font('regular'); $bold = og_font('bold'); $mono = og_font('mono'); $maxW = OG_WIDTH - (OG_PAD * 2); // Brand line. $y = 118; og_text($im, og_ellipsize((string) ($card['brand'] ?? ''), $mono, 22, $maxW), $mono, 22, OG_PAD, $y, $muted); // Title — up to two lines, shrinking once if a long name would wrap. $titleSize = 58; $title = trim((string) ($card['title'] ?? '')); if (og_text_width($title, $bold, $titleSize) > $maxW * 2) { $titleSize = 46; } $y += 96; foreach (og_wrap($title, $bold, $titleSize, $maxW, 2) as $line) { og_text($im, $line, $bold, $titleSize, OG_PAD, $y, $fg); $y += (int) round($titleSize * 1.32); } // Slug. $subtitle = trim((string) ($card['subtitle'] ?? '')); if ($subtitle !== '') { $y += 4; og_text($im, og_ellipsize($subtitle, $mono, 24, $maxW), $mono, 24, OG_PAD, $y, $accent); $y += 34; } $footerY = OG_HEIGHT - 118; // Description — as many lines as clear the footer rule, up to three. A // title that wrapped to two lines eats into the space available here. $description = trim((string) ($card['description'] ?? '')); if ($description !== '') { $y += 30; $room = min(3, intdiv(max(0, $footerY - 16 - $y), 44) + 1); foreach (og_wrap($description, $regular, 27, $maxW, $room) as $line) { og_text($im, $line, $regular, 27, OG_PAD, $y, $body); $y += 44; } } // Footer rule + stat row — omitted entirely when there is nothing to show. $language = trim((string) ($card['language'] ?? '')); $stats = array_values(array_filter(array_map('strval', (array) ($card['stats'] ?? [])))); if ($language === '' && !$stats) { return $im; } imagefilledrectangle($im, OG_PAD, $footerY, OG_WIDTH - OG_PAD, $footerY, $border); $x = OG_PAD; $baseline = $footerY + 62; if ($language !== '') { $badgeW = og_text_width($language, $regular, 22) + 66; og_rounded_rect($im, $x, $baseline - 30, $x + $badgeW, $baseline + 12, 21, $panel); $dot = og_hex_color($im, OG_LANG_COLORS[$language] ?? '#8B949E'); imagefilledellipse($im, $x + 26, $baseline - 9, 16, 16, $dot); og_text($im, $language, $regular, 22, $x + 44, $baseline, $body); $x += $badgeW + 28; } foreach ($stats as $i => $stat) { if ($i > 0) { og_text($im, '·', $regular, 22, $x, $baseline, $border); $x += 24; } og_text($im, $stat, $regular, 22, $x, $baseline, $muted); $x += og_text_width($stat, $regular, 22) + 20; } return $im; } // ---- Repository cards ------------------------------------------------------- /** * Cache-busting digest of everything the card displays. Any change here yields * a new og:image URL, which is what forces crawlers to re-fetch. */ function repo_og_hash(array $repo): string { $files = function_exists('get_files_by_repo') && !empty($repo['id']) ? get_files_by_repo((int) $repo['id']) : []; $key = implode('|', [ (string) ($repo['slug'] ?? ''), (string) ($repo['name'] ?? ''), (string) ($repo['description'] ?? ''), repo_language_display($repo), (string) count($files), (string) array_sum(array_map(static fn($f) => (int) ($f['filesize'] ?? 0), $files)), ]); return substr(md5($key), 0, 12); } /** Absolute og:image URL for a repository, or null when GD cannot render. */ function repo_og_url(array $repo): ?string { if (!og_available() || empty($repo['slug'])) { return null; } return abs_url('/' . $repo['slug'] . '/og-' . repo_og_hash($repo) . '.png'); } /** Card fields for a repository row. */ function repo_og_card(array $repo): array { $files = function_exists('get_files_by_repo') && !empty($repo['id']) ? get_files_by_repo((int) $repo['id']) : []; $count = count($files); $bytes = array_sum(array_map(static fn($f) => (int) ($f['filesize'] ?? 0), $files)); $stats = [$count . ' ' . ($count === 1 ? 'file' : 'files')]; if ($bytes > 0) { $stats[] = human_size($bytes); } $created = strtotime((string) ($repo['created_at'] ?? '')); if ($created) { $stats[] = date('M Y', $created); } return [ 'brand' => "jefftml's code", 'title' => (string) ($repo['name'] ?? ''), 'subtitle' => '/' . (string) ($repo['slug'] ?? ''), 'description' => (string) ($repo['description'] ?? ''), 'language' => repo_language_display($repo), 'stats' => $stats, ]; } /** Absolute path of the on-disk cache entry for a rendered card. */ function repo_og_cache_path(string $slug, string $hash): string { return UPLOAD_DIR . '/og/' . $slug . '-' . $hash . '.png'; } /** * Render (or reuse) the cached PNG for a repository and return its path, or * null if it could not be written. */ function repo_og_file(array $repo, string $hash): ?string { $path = repo_og_cache_path((string) $repo['slug'], $hash); if (is_readable($path) && filesize($path) > 0) { return $path; } $dir = dirname($path); if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { return null; } $im = og_render_card(repo_og_card($repo)); // Write to a temporary name first so a concurrent request never serves a // half-written PNG. $tmp = $path . '.' . getmypid() . '.tmp'; $ok = imagepng($im, $tmp, 6); unset($im); if (!$ok || !@rename($tmp, $path)) { @unlink($tmp); return null; } // Drop cards rendered from older states of this repository. foreach (glob($dir . '/' . $repo['slug'] . '-*.png') ?: [] as $old) { if ($old !== $path) { @unlink($old); } } return $path; } /** * Router entry point for /{slug}/og[-{hash}].png. Serves the current card * whatever hash was asked for — the hash is a cache key, not a lookup key — * but only promises immutability when it matches the current state. */ function render_repo_og(string $slug, string $requestedHash): void { $repo = get_repository_by_slug($slug); if (!$repo || !og_available()) { og_error(404); } $hash = repo_og_hash($repo); $path = repo_og_file($repo, $hash); if ($path === null) { og_error(500); } $fresh = ($requestedHash === $hash); $etag = '"' . $hash . '"'; header('Content-Type: image/png'); header('ETag: ' . $etag); header($fresh ? 'Cache-Control: public, max-age=31536000, immutable' : 'Cache-Control: public, max-age=300'); if (trim((string) ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) { http_response_code(304); return; } header('Content-Length: ' . filesize($path)); readfile($path); } /** Bail out of the image endpoint without emitting HTML chrome. */ function og_error(int $code): void { http_response_code($code); header('Content-Type: text/plain; charset=utf-8'); header('Cache-Control: no-store'); echo $code === 404 ? "Not found\n" : "Unable to render the social card\n"; exit; }