Skip to content

Commit 73242bb

Browse files
committed
Generate content social images
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eef8b9b2-19e9-4014-80c0-1afcf2d4e9ff
1 parent a6f3d90 commit 73242bb

3 files changed

Lines changed: 150 additions & 6 deletions

File tree

‎src/lib/social-images.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
const CLOUDINARY_BASE = 'https://res.cloudinary.com/dk3rdh3yo/image/upload';
2+
const BACKGROUND_COUNT = 6;
3+
4+
export interface ContentSocialImage {
5+
key: string;
6+
title: string;
7+
topics: string[];
8+
}
9+
10+
function encodeText(value: string): string {
11+
return encodeURIComponent(value.toLocaleUpperCase('en-US')).replace(
12+
/[!'()*]/g,
13+
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`
14+
);
15+
}
16+
17+
export function splitSocialTitle(title: string): { main: string; ending: string } {
18+
const normalized = title.trim().replace(/\s+/g, ' ');
19+
if (!normalized) throw new Error('A social image title cannot be empty.');
20+
21+
const words = normalized.split(' ');
22+
const endingLength = Math.min(5, Math.max(1, Math.ceil(words.length * 0.4)));
23+
24+
return {
25+
main: words.slice(0, -endingLength).join(' ') || normalized,
26+
ending: words.slice(-endingLength).join(' ')
27+
};
28+
}
29+
30+
export function buildContentSocialImage(
31+
content: Omit<ContentSocialImage, 'key'>,
32+
background: number
33+
): string {
34+
if (!Number.isInteger(background) || background < 1 || background > BACKGROUND_COUNT) {
35+
throw new RangeError(`Social image background must be between 1 and ${BACKGROUND_COUNT}.`);
36+
}
37+
38+
const topics = [...new Set(content.topics.map((topic) => topic.trim()).filter(Boolean))];
39+
if (!topics.length) throw new Error('A social image needs at least one topic.');
40+
41+
const title = splitSocialTitle(content.title);
42+
const topicLayer =
43+
`co_%23e83a47,l_text:Fira%20Mono_20:${encodeText(topics.join(' | '))}` +
44+
'/fl_layer_apply,g_north_west,x_60,y_60';
45+
const titleLayer =
46+
`w_630,c_fit,co_white,b_rgb:00000080,l_text:Archivo%20Black_60_line_spacing_-20:${encodeText(title.main)}` +
47+
'/fl_layer_apply,g_south_west,x_60,y_180';
48+
const endingLayer =
49+
`bo_15px_solid_%23e83a47,b_%23e83a47,co_%23000000,l_text:Archivo%20Black_32:${encodeText(title.ending)},c_fit,w_600,h_50` +
50+
'/fl_layer_apply,g_south_west,x_60,y_100';
51+
52+
return `${CLOUDINARY_BASE}/${topicLayer}/${titleLayer}/${endingLayer}/v1/ograph/ograph_${background}.png`;
53+
}
54+
55+
export function assignContentSocialImages(
56+
content: readonly ContentSocialImage[]
57+
): Map<string, string> {
58+
const ordered = [...content].sort((left, right) =>
59+
left.key < right.key ? -1 : left.key > right.key ? 1 : 0
60+
);
61+
62+
return new Map(
63+
ordered.map((item, index) => [
64+
item.key,
65+
buildContentSocialImage(item, (index % BACKGROUND_COUNT) + 1)
66+
])
67+
);
68+
}

‎src/pages/[topic]/[slug].astro‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,41 @@ import ShareMenu from '../../components/ShareMenu.astro';
88
import { topicBySlug } from '../../config/site';
99
import { isVideoKind, itemKindLabel, pagedItems, type Item } from '../../lib/content';
1010
import { bakedLike } from '../../lib/likes';
11+
import { assignContentSocialImages } from '../../lib/social-images';
1112
import { videoTranscripts } from '../../lib/video-transcripts';
1213
1314
export async function getStaticPaths() {
1415
const [items, blog] = await Promise.all([pagedItems(), getCollection('blog')]);
1516
const posts = new Map(blog.map((post) => [post.id, post]));
16-
return items.map((item) => ({
17-
params: { topic: item.topic, slug: item.slug },
18-
props: { item, post: item.kind === 'article' ? posts.get(item.key.slice(5)) : undefined }
19-
}));
17+
const socialImages = assignContentSocialImages(
18+
items.map((item) => ({
19+
key: item.key,
20+
title: item.title,
21+
topics: [item.topic, ...item.alsoFiled].map((slug) => {
22+
const topic = topicBySlug(slug);
23+
if (!topic) throw new Error(`No configured topic for "${slug}" on ${item.key}.`);
24+
return topic.title;
25+
})
26+
}))
27+
);
28+
29+
return items.map((item) => {
30+
const socialImage = socialImages.get(item.key);
31+
if (!socialImage) throw new Error(`No social image generated for ${item.key}.`);
32+
33+
return {
34+
params: { topic: item.topic, slug: item.slug },
35+
props: {
36+
item,
37+
socialImage,
38+
post: item.kind === 'article' ? posts.get(item.key.slice(5)) : undefined
39+
}
40+
};
41+
});
2042
}
2143
2244
type Post = Awaited<ReturnType<typeof getCollection<'blog'>>>[number];
23-
const { item, post } = Astro.props as { item: Item; post?: Post };
45+
const { item, post, socialImage } = Astro.props as { item: Item; post?: Post; socialImage: string };
2446
const pageUrl = item.url!;
2547
const topic = topicBySlug(item.topic)!;
2648
const Content = post ? (await render(post)).Content : null;
@@ -69,7 +91,7 @@ const likes = await bakedLike('content', item.key);
6991
title={item.title}
7092
description={item.description || `${itemKindLabel(item.kind)} about ${topic.title}.`}
7193
active={video ? 'videos' : 'articles'}
72-
image={item.thumbnail ?? undefined}
94+
image={socialImage}
7395
noindex={item.draft}
7496
canonical={post?.data.canonicalUrl}
7597
>

‎tests/social-images.test.mjs‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { test } from 'node:test';
22
import assert from 'node:assert/strict';
33
import fs from 'node:fs';
44
import path from 'node:path';
5+
import {
6+
assignContentSocialImages,
7+
buildContentSocialImage,
8+
splitSocialTitle
9+
} from '../src/lib/social-images.ts';
510

611
const root = process.cwd();
712
const read = (...parts) => fs.readFileSync(path.join(root, ...parts), 'utf8');
@@ -10,6 +15,7 @@ const config = read('src', 'config', 'site.ts');
1015
const base = read('src', 'layouts', 'Base.astro');
1116
const home = read('src', 'pages', 'index.astro');
1217
const about = read('src', 'pages', 'about.astro');
18+
const detail = read('src', 'pages', '[topic]', '[slug].astro');
1319

1420
test('page groups use their intended social images', () => {
1521
assert.match(config, /home: 'https:\/\/res\.cloudinary\.com\/.+\/ograph\/home_[^']+\.png'/);
@@ -26,3 +32,51 @@ test('Open Graph and Twitter publish the same large image', () => {
2632
assert.match(base, /name="twitter:image" content=\{socialImage\}/);
2733
assert.match(base, /name="twitter:image:alt" content=\{pageTitle\}/);
2834
});
35+
36+
test('content pages use encoded Cloudinary title and topic layers', () => {
37+
const image = buildContentSocialImage(
38+
{
39+
title: 'Five DI Anti-Patterns Haunting .NET Apps and how to fix Them',
40+
topics: ['Dependency Injection', 'C#']
41+
},
42+
3
43+
);
44+
45+
assert.equal(
46+
image,
47+
'https://res.cloudinary.com/dk3rdh3yo/image/upload/' +
48+
'co_%23e83a47,l_text:Fira%20Mono_20:DEPENDENCY%20INJECTION%20%7C%20C%23/' +
49+
'fl_layer_apply,g_north_west,x_60,y_60/' +
50+
'w_630,c_fit,co_white,b_rgb:00000080,l_text:Archivo%20Black_60_line_spacing_-20:' +
51+
'FIVE%20DI%20ANTI-PATTERNS%20HAUNTING%20.NET%20APPS/' +
52+
'fl_layer_apply,g_south_west,x_60,y_180/' +
53+
'bo_15px_solid_%23e83a47,b_%23e83a47,co_%23000000,l_text:Archivo%20Black_32:' +
54+
'AND%20HOW%20TO%20FIX%20THEM,c_fit,w_600,h_50/' +
55+
'fl_layer_apply,g_south_west,x_60,y_100/v1/ograph/ograph_3.png'
56+
);
57+
assert.deepEqual(splitSocialTitle('A short title'), {
58+
main: 'A',
59+
ending: 'short title'
60+
});
61+
assert.match(detail, /image=\{socialImage\}/);
62+
assert.doesNotMatch(detail, /image=\{item\.thumbnail/);
63+
});
64+
65+
test('content pages distribute backgrounds evenly in stable key order', () => {
66+
const content = Array.from({ length: 14 }, (_, index) => ({
67+
key: `content:${String(index).padStart(2, '0')}`,
68+
title: `Content title number ${index}`,
69+
topics: ['C#']
70+
})).reverse();
71+
const images = assignContentSocialImages(content);
72+
const counts = Array.from({ length: 6 }, () => 0);
73+
74+
for (const image of images.values()) {
75+
const background = Number(image.match(/ograph_(\d)\.png$/)?.[1]);
76+
counts[background - 1] += 1;
77+
}
78+
79+
assert.deepEqual(counts, [3, 3, 2, 2, 2, 2]);
80+
assert.match(images.get('content:00'), /ograph_1\.png$/);
81+
assert.match(images.get('content:05'), /ograph_6\.png$/);
82+
});

0 commit comments

Comments
 (0)