Open Graph

There are now Open Graph informations and images available when a URL is shared.
For the image, I create a SVG image. I did not create JPEG or PNG. I did something simple and easily maintainable.
However, some improvements are still needed, particularly for the photos. When a title is too long, it appears under the photo, and I have to correct it. Despite this, I decided to implement them because it’s cool.

To see the result of the OG images, just add .svg at the end of the URL like that: alienlebarge.ch/changelogs/2025-05-07-15-53.svg

Here’s the code of the template

<?php
use Kirby\Toolkit\Str;

/**
 * Text formatting configuration
 */
$params = $params ?? [];
$TITLE_LENGTH = $params['titleLength'] ?? 50;        // Maximum title length in characters
$MAX_TITLE_LENGTH = $params['maxTitleLength'] ?? 30; // Maximum characters per line for title
$EXCERPT_LENGTH = $params['excerptLength'] ?? 180;   // Maximum excerpt length
$MAX_EXCERPT_LENGTH = $params['maxExcerptLength'] ?? 50; // Maximum characters per line for excerpt

/**
 * Split text into lines respecting maximum length
 * 
 * @param string $text Text to split
 * @param int $maxLength Maximum length per line
 * @return array Array of text lines
 */
function splitTextIntoLines(string $text, int $maxLength): array {
    if (empty($text)) {
        return [];
    }

    $words = explode(' ', $text);
    $lines = [];
    $currentLine = '';

    foreach ($words as $word) {
        if (strlen($currentLine . ' ' . $word) <= $maxLength) {
            $currentLine .= ($currentLine === '' ? '' : ' ') . $word;
        } else {
            $lines[] = $currentLine;
            $currentLine = $word;
        }
    }

    if ($currentLine !== '') {
        $lines[] = $currentLine;
    }

    return $lines;
}

/**
 * Prepare and clean data
 */
$title = $page->title()->value() ?? '';
$title = Str::short($title, $TITLE_LENGTH);
$title = html_entity_decode($title, ENT_QUOTES | ENT_HTML5, 'UTF-8');

$excerpt = $page->summary()->exists() 
    ? $page->summary()->value() 
    : $page->text()->excerpt($EXCERPT_LENGTH)->value() ?? '';
$excerpt = html_entity_decode($excerpt, ENT_QUOTES | ENT_HTML5, 'UTF-8');

// Split title and excerpt into lines
$titleLines = splitTextIntoLines($title, $MAX_TITLE_LENGTH);
$excerptLines = splitTextIntoLines($excerpt, $MAX_EXCERPT_LENGTH);
?>

<svg width="1200" height="630" 
     xmlns="http://www.w3.org/2000/svg" 
     viewBox="0 0 1200 630" 
     role="img" 
     aria-label="<?= htmlspecialchars($title) ?>">

    <!-- Styles -->
    <style>
        /* Fonts */
        @font-face {
            font-family: "Untitled";
            src: url("https://alienlebarge.ch/assets/fonts/Untitled.woff2") format("woff2");
        }

        @font-face {
            font-family: "UntitledMono";
            src: url("https://alienlebarge.ch/assets/fonts/UntitledMono.woff2") format("woff2");
        }

        /* Base styles */
        text {
            font-family: "Untitled", sans-serif;
        }

        /* Heading styles */
        .heading {
            font-size: 72px;
            font-weight: 800;
            font-variation-settings: "wght" 800;
            hanging-punctuation: first;
            letter-spacing: 0;
        }

        /* Body text styles */
        .body {
            font-size: 40px;
            font-weight: 400;
            font-variant: oldstyle-nums;
            font-variation-settings: "wght" var(--font-weight-body, 400);
            font-weight: var(--font-weight-body, 400);
            hanging-punctuation: first;
            hyphens: auto;
        }

        /* Logo styles */
        .logo-text {
            font-size: 30px;
            font-weight: 600;
        }

        .logo svg {
            border-radius: 50%;
            height: 2em;
            width: 2em;
        }

        /* Reference styles */
        .reference {
            font-family: "UntitledMono", sans-serif;
            font-size: 18px;
            font-weight: 400;
        }
    </style>

    <!-- Definitions -->
    <defs>
        <clipPath id="circleClip">
            <circle cx="250" cy="250" r="250" />
        </clipPath>
        <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
            <feDropShadow 
                dx="0" 
                dy="4" 
                stdDeviation="8" 
                flood-color="rgba(0,0,0,0.2)"
            />
        </filter>
    </defs>

    <!-- Background -->
    <rect width="1200" height="630" fill="hsl(0deg 0% 96% / 100%)" />

    <!-- Logo -->
    <?php if ($file = $site->files()->filterBy('extension', 'svg')->first()): 
        $scale = 50 / $file->width();
    ?>
    <g class="logo" 
       transform="translate(100, 20), scale(<?= $scale ?>)" 
       clip-path="url(#circleClip)" 
       role="img" 
       aria-label="Site logo">
        <?= svg($file) ?>
    </g>
    <?php endif ?>

    <!-- Logo text -->
    <text class="logo-text" 
          y="55" 
          dominant-baseline="middle" 
          text-anchor="start" 
          role="banner" 
          aria-level="1">
        <tspan x="170"><?= $site->title() ?></tspan><tspan fill="hsl(0deg 0% 55% / 100%)">.ch</tspan>
    </text>

    <!-- Title -->
    <text class="heading" 
          y="200" 
          dominant-baseline="middle" 
          text-anchor="start" 
          role="heading" 
          aria-level="1">
        <?php foreach ($titleLines as $index => $line): ?>
            <tspan x="100" dy="<?= $index === 0 ? '0' : '1.2em' ?>">
                <?= htmlspecialchars($line) ?>
            </tspan>
        <?php endforeach; ?>
    </text>

    <!-- Excerpt -->
    <text class="body" 
          y="370" 
          dominant-baseline="middle" 
          text-anchor="start" 
          role="doc-abstract">
        <?php foreach ($excerptLines as $index => $line): ?>
            <tspan x="100" dy="<?= $index === 0 ? '0' : '1.2em' ?>">
                <?= htmlspecialchars($line) ?>
            </tspan>
        <?php endforeach; ?>
    </text>

    <?php if ($page->template()->name() === 'photo'): ?>
    <!-- Images -->
    <?php 
    $images = $page->images();
    if ($image1 = $images->first()): ?>
    <!-- First image -->
    <g transform="rotate(10, 975, 375)">
        <!-- White background for image -->
        <rect 
            x="780"
            y="175"
            width="350"
            height="400"
            fill="white"
            filter="url(#shadow)"
        />
        <!-- Photo image -->
        <image 
            href="<?= $image1->thumb(['width' => 300, 'height' => 300])->url() ?>"
            x="805"
            y="200"
            width="300"
            height="300"
            preserveAspectRatio="xMidYMid slice"
            role="img"
            aria-label="<?= $image1->alt()->or($page->title()) ?>"
        />
    </g>
    <?php endif; ?>

    <?php if ($image2 = $images->nth(1)): ?>
    <!-- Second image -->
    <g transform="rotate(-5, 975, 375)">
        <!-- White background for image -->
        <rect 
            x="780"
            y="175"
            width="350"
            height="400"
            fill="white"
            filter="url(#shadow)"
        />
        <!-- Photo image -->
        <image 
            href="<?= $image2->thumb(['width' => 300, 'height' => 300])->url() ?>"
            x="805"
            y="200"
            width="300"
            height="300"
            preserveAspectRatio="xMidYMid slice"
            role="img"
            aria-label="<?= $image2->alt()->or($page->title()) ?>"
        />
    </g>
    <?php endif; ?>
    <?php endif ?>

    <!-- Date -->
    <text class="reference" 
          y="600" 
          dominant-baseline="middle" 
          text-anchor="start" 
          role="doc-abstract">
        <tspan x="100" fill="hsl(0deg 0% 55% / 100%)">
            <?= $page->date()->toDate('Y-m-d') ?>
        </tspan>
    </text>
</svg>