Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
node_modules/
.cache/
public
.env
.env.*
!.env.example
Expand Down
Binary file added public/content/landing/family.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/content/landing/raae.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/content/landing/youtube.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/content/talks/mpya-speaking.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/raae-avatar.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/raae.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/atila-early-adopter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/colby_fayock_tweet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/galactic-pow-talk.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/gatsby-plugin-starter-ash.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/george-shipmate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/gtchakama-emails-streams.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/neil-tweet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/outseta-demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/paul-daily-emails.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/person-of-the-week.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/testimonials/w_baslow_tweet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ const {
siteTwitterCreator,
} = siteMetadata;

const deployUrl = import.meta.env.DEPLOY_PRIME_URL || siteUrl;
const siteTitle = `${siteName} — ${siteTagline}`;
const pageTitle = title ? `${title} — ${siteName}` : siteTitle;
const pageImage = image || siteSocialImage;
const pageDescription = description || siteDescription;
const canonical = canonicalProp || `${siteUrl}${Astro.url.pathname}`;
const socialType = type;
const socialTitle = pageTitle;
const socialImage = pageImage && `${siteUrl}${pageImage}`;
const socialImage = pageImage && `${deployUrl}${pageImage}`;
const socialImageAlt = image ? imageAlt : siteSocialImageAlt;
const socialDescription = pageDescription;
const twitterSite = siteTwitterCreator;
Expand Down
225 changes: 142 additions & 83 deletions src/lib/og-image.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,112 @@
import satori from 'satori';
import sharp from 'sharp';
import fs from 'node:fs';
import path from 'node:path';
import satori from "satori";
import sharp from "sharp";
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";

// ── Author config matching Gatsby plugin ──────────────────────────
export const AUTHOR_CONFIG: Record<
string,
{ secondaryColor: string; avatar: string; signature: string }
> = {
export const AUTHOR_CONFIG: Record<string, { secondaryColor: string; avatar: string; signature: string }> = {
Queen: {
secondaryColor: '#ffde59',
avatar: 'queen-avatar.jpg',
signature: 'queen.raae.codes',
secondaryColor: "#ffde59",
avatar: "queen-avatar.jpg",
signature: "queen.raae.codes",
},
OlaVea: {
secondaryColor: '#5DADE2',
avatar: 'olavea-avatar.jpg',
secondaryColor: "#5DADE2",
avatar: "olavea-avatar.jpg",
signature: "Cap'n Ola (queen.raae.codes)",
},
};

// ── Design constants (matching Gatsby canvas) ─────────────────────
const WIDTH = 1200;
const HEIGHT = 628;
const BG_COLOR = '#fffaf0';
const PRIMARY_COLOR = '#ec4326';
const PRIMARY_TEXT = '#412f20';
const SECONDARY_TEXT = '#412f20bb';
const BG_COLOR = "#fffaf0";
const PRIMARY_COLOR = "#ec4326";
const PRIMARY_TEXT = "#412f20";
const SECONDARY_TEXT = "#412f20bb";

// ── Cached assets (loaded once per build) ─────────────────────────
const assetsDir = path.resolve(process.cwd(), 'src/assets/og');
const assetsDir = path.resolve(process.cwd(), "src/assets/og");

let fontRegular: Buffer | null = null;
let fontBold: Buffer | null = null;
const avatarCache = new Map<string, string>();

function loadFonts() {
if (!fontRegular) fontRegular = fs.readFileSync(path.join(assetsDir, 'Roboto-Regular.ttf'));
if (!fontBold) fontBold = fs.readFileSync(path.join(assetsDir, 'Roboto-Bold.ttf'));
if (!fontRegular) fontRegular = fs.readFileSync(path.join(assetsDir, "Roboto-Regular.ttf"));
if (!fontBold) fontBold = fs.readFileSync(path.join(assetsDir, "Roboto-Bold.ttf"));
}

function getAvatarDataUri(filename: string): string {
if (avatarCache.has(filename)) return avatarCache.get(filename)!;
const buf = fs.readFileSync(path.join(assetsDir, filename));
const uri = `data:image/jpeg;base64,${buf.toString('base64')}`;
const uri = `data:image/jpeg;base64,${buf.toString("base64")}`;
avatarCache.set(filename, uri);
return uri;
}

// ── Emoji support (Twemoji SVGs via Satori's loadAdditionalAsset) ─
const U200D = String.fromCharCode(8205);
const UFE0Fg = /\uFE0F/g;

function toCodePoint(unicodeSurrogates: string): string {
const r: string[] = [];
let c = 0,
p = 0,
i = 0;
while (i < unicodeSurrogates.length) {
c = unicodeSurrogates.charCodeAt(i++);
if (p) {
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16));
p = 0;
} else if (55296 <= c && c <= 56319) {
p = c;
} else {
r.push(c.toString(16));
}
}
return r.join("-");
}

function getIconCode(char: string): string {
return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, "") : char);
}

const emojiCache = new Map<string, string>();

async function loadEmoji(code: string): Promise<string> {
if (emojiCache.has(code)) return emojiCache.get(code)!;
const url = `https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${code.toLowerCase()}.svg`;
const res = await fetch(url);
const svg = await res.text();
emojiCache.set(code, svg);
return svg;
}

// ── Text truncation (Satori has no line-clamp) ────────────────────
function truncateText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
const truncated = text.substring(0, maxChars);
const lastSpace = truncated.lastIndexOf(' ');
return (lastSpace > 0 ? truncated.substring(0, lastSpace) : truncated) + '…';
const lastSpace = truncated.lastIndexOf(" ");
return (lastSpace > 0 ? truncated.substring(0, lastSpace) : truncated) + "…";
}

// ── Deterministic image path for a post slug ──────────────────────
export function getOgImagePath(slug: string): string {
const name = slug.replace(/^\/|\/$/g, '');
const name = slug.replace(/^\/|\/$/g, "");
return `/og/${name}.png`;
}

// ── Cache-busting URL (hash changes when title/description change) ─
export function getOgImageUrl(slug: string, title: string, description: string): string {
const hash = createHash("md5")
.update(`${title}|${description}`)
.digest("hex")
.slice(0, 8);
return `${getOgImagePath(slug)}?v=${hash}`;
}

// ── Generate the PNG buffer ───────────────────────────────────────
export async function generateOgImage(options: {
title: string;
Expand All @@ -72,122 +117,130 @@ export async function generateOgImage(options: {

const config = AUTHOR_CONFIG[options.author] || AUTHOR_CONFIG.Queen;
const avatarUri = getAvatarDataUri(config.avatar);
const title = truncateText(options.title, 120);
const title = truncateText(options.title, 60);
const description = truncateText(options.description, 220);

// Build a plain VDOM object for Satori (avoids satori-html wrapping issues)
// Gatsby-matching avatar geometry
const AVATAR_DIAMETER = HEIGHT; // 628px — same as canvas height
const AVATAR_RADIUS = AVATAR_DIAMETER / 2; // 314px
const AVATAR_BORDER = Math.round(HEIGHT * 0.03); // ~19px
const AVATAR_CX = WIDTH - AVATAR_RADIUS * 0.5; // 1043
const AVATAR_CY = AVATAR_RADIUS * 1.2; // 376.8
const COPY_WIDTH = Math.round(AVATAR_CX - AVATAR_RADIUS - WIDTH * 0.05 * 2); // 609

// Build a plain VDOM object for Satori
const markup = {
type: 'div',
type: "div",
props: {
style: {
display: 'flex',
flexDirection: 'column',
display: "flex",
flexDirection: "column",
position: "relative",
width: `${WIDTH}px`,
height: `${HEIGHT}px`,
backgroundColor: BG_COLOR,
overflow: "hidden",
},
children: [
// Avatar — absolutely positioned, overflows right & bottom edges
// Placed first so text paints on top (SVG paint order = DOM order)
{
type: "img",
props: {
src: avatarUri,
style: {
position: "absolute",
top: `${Math.round(AVATAR_CY - AVATAR_RADIUS)}px`,
left: `${Math.round(AVATAR_CX - AVATAR_RADIUS)}px`,
width: `${AVATAR_DIAMETER}px`,
height: `${AVATAR_DIAMETER}px`,
borderRadius: `${AVATAR_RADIUS}px`,
border: `${AVATAR_BORDER}px solid ${config.secondaryColor}`,
objectFit: "cover",
},
},
},
// Red top border
{
type: 'div',
type: "div",
props: {
style: {
width: '100%',
height: '12px',
width: "100%",
height: "12px",
backgroundColor: PRIMARY_COLOR,
},
},
},
// Main content area
{
type: 'div',
type: "div",
props: {
style: {
display: 'flex',
display: "flex",
flex: 1,
padding: '48px 60px 0 60px',
padding: "48px 60px 0 60px",
},
children: [
// Left: text
// Text column — allowed to overlap the avatar
{
type: 'div',
type: "div",
props: {
style: {
display: 'flex',
flexDirection: 'column',
flex: 1,
justifyContent: 'center',
paddingRight: '40px',
display: "flex",
flexDirection: "column",
maxWidth: `${COPY_WIDTH + 270}px`,
justifyContent: "center",
overflow: "hidden",
},
children: [
{
type: 'div',
type: "div",
props: {
style: {
fontFamily: 'Roboto',
fontFamily: "Roboto",
fontWeight: 700,
fontSize: '48px',
fontSize: "48px",
color: PRIMARY_TEXT,
lineHeight: 1.2,
overflow: 'hidden',
textWrap: "balance",
overflow: "hidden",
backgroundColor: BG_COLOR,
borderRadius: "20px",
padding: "18px 18px 18px 0",
},
children: title,
},
},
{
type: 'div',
type: "div",
props: {
style: {
fontFamily: 'Roboto',
fontSize: '24px',
fontFamily: "Roboto",
fontSize: "24px",
color: SECONDARY_TEXT,
marginTop: '20px',
marginTop: "20px",
lineHeight: 1.35,
overflow: 'hidden',
overflow: "hidden",
maxWidth: `${COPY_WIDTH}px`,
},
children: description,
},
},
],
},
},
// Right: avatar
{
type: 'div',
props: {
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
children: {
type: 'img',
props: {
src: avatarUri,
style: {
width: '280px',
height: '280px',
borderRadius: '140px',
border: `8px solid ${config.secondaryColor}`,
objectFit: 'cover',
},
},
},
},
},
],
},
},
// Footer
{
type: 'div',
type: "div",
props: {
style: {
display: 'flex',
padding: '0 60px 28px 60px',
fontFamily: 'Roboto',
fontSize: '22px',
display: "flex",
padding: "0 60px 28px 60px",
fontFamily: "Roboto",
fontSize: "22px",
color: PRIMARY_COLOR,
},
children: config.signature,
Expand All @@ -201,9 +254,15 @@ export async function generateOgImage(options: {
width: WIDTH,
height: HEIGHT,
fonts: [
{ name: 'Roboto', data: fontRegular!, weight: 400, style: 'normal' },
{ name: 'Roboto', data: fontBold!, weight: 700, style: 'normal' },
{ name: "Roboto", data: fontRegular!, weight: 400, style: "normal" },
{ name: "Roboto", data: fontBold!, weight: 700, style: "normal" },
],
loadAdditionalAsset: async (code: string, segment: string) => {
if (code === "emoji") {
return `data:image/svg+xml;base64,${Buffer.from(await loadEmoji(getIconCode(segment))).toString("base64")}`;
}
return code;
},
});

return await sharp(Buffer.from(svg)).png().toBuffer();
Expand Down
6 changes: 3 additions & 3 deletions src/pages/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Newsletter from '../components/Newsletter.astro';
import Posts from '../components/Posts.astro';
import { getAllPosts, getRelatedPosts } from '../lib/posts';
import { getDisclaimers } from '../lib/disclaimers';
import { getOgImagePath } from '../lib/og-image';
import { getOgImagePath, getOgImageUrl } from '../lib/og-image';
import siteMetadata from '../data/siteMetadata';

export async function getStaticPaths() {
Expand Down Expand Up @@ -41,14 +41,14 @@ const isOla = post.author === 'OlaVea';
const authorName = isOla ? 'Ola Vea' : 'Benedicte Raae';
const authorTwitter = isOla ? 'https://twitter.com/olaholstvea' : 'https://twitter.com/raae';

const ogImagePath = getOgImagePath(post.slug);
const ogImagePath = getOgImageUrl(post.slug, post.title, post.description);

// Schema.org structured data
const schema = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
image: [`${siteMetadata.siteUrl}${ogImagePath}`],
image: [`${import.meta.env.DEPLOY_PRIME_URL || siteMetadata.siteUrl}${ogImagePath}`],
datePublished: post.dateISO,
author: [
{
Expand Down
Loading