Skip to content

Commit 508fa39

Browse files
committed
Refactor OG images
1 parent a9fba6f commit 508fa39

File tree

3 files changed

+57
-56
lines changed

3 files changed

+57
-56
lines changed

packages/docs/src/assets/og-template.svg

Lines changed: 6 additions & 2 deletions
Loading

packages/docs/src/components/Head.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
import Default from '@astrojs/starlight/components/Head.astro';
33
44
const slug = Astro.locals.starlightRoute.slug;
5+
56
const url = new URL(Astro.url);
67
url.pathname = `/og/docs${slug ? `/${slug}` : ''}.webp`;
7-
url.searchParams.append('v', '4');
88
---
99

1010
<Default {...Astro.props} />

packages/docs/src/pages/og/[...route].ts

Lines changed: 50 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,86 +3,83 @@ import { createRequire } from 'node:module';
33
// biome-ignore lint/nursery/noRestrictedImports: ignore
44
import { resolve } from 'node:path';
55

6-
// We can't import sharp normally because it's a CJS thing and those don't seems to work well with Astro, Vite, everyone
76
const require = createRequire(import.meta.url);
87
// ts-expect-error TS80005
98
const sharp = require('sharp');
109

1110
const template = readFileSync(resolve('src/assets/og-template.svg'), 'utf-8');
1211

13-
export function breakText(str: string, maxLines: number, maxLineLen: number) {
14-
const segmenterTitle = new Intl.Segmenter('en-US', { granularity: 'word' });
15-
const segments = segmenterTitle.segment(str);
16-
17-
const linesOut = [''];
18-
let lineNo = 0;
19-
let offsetInLine = 0;
20-
for (const word of Array.from(segments)) {
21-
if (offsetInLine + word.segment.length >= maxLineLen) {
22-
lineNo++;
23-
offsetInLine = 0;
24-
linesOut.push('');
25-
}
26-
27-
if (lineNo >= maxLines) {
28-
return linesOut.slice(0, maxLines);
29-
}
30-
31-
linesOut[lineNo] += word.segment.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
32-
offsetInLine += word.segment.length;
33-
}
34-
35-
return linesOut;
36-
}
37-
3812
const getPages = async () => {
3913
const data = import.meta.glob(['/src/content/**/*.{md,mdx}'], { eager: true });
4014
const pages: Record<string, unknown> = {};
4115
for (const [filePath, page] of Object.entries(data)) {
4216
const imagePath = filePath.replace(/^\/src\/content\//, '').replace(/(\/index)?\.(md|mdx)$/, '.webp');
4317
pages[imagePath] = page;
4418
}
45-
46-
pages['docs/sponsors.webp'] = {
47-
frontmatter: { title: 'Become a sponsor!', description: 'Become a sponsor of Knip' },
48-
};
49-
50-
pages['docs/playground.webp'] = {
51-
frontmatter: { title: 'Playground', description: 'Try Knip in your browser' },
52-
};
53-
5419
return pages;
5520
};
5621

57-
const renderSVG = ({ title }: { title: string; description: string[] }) => {
58-
const titleText = breakText(title, 2, 45)
59-
.map((text, i, texts) => {
60-
const m = (texts.length === 1 ? 0 : -75) / 2;
61-
const y = (1000 + m + 150 * i) / 2;
62-
const s = (texts.length === 1 ? 150 : 96) / 2;
63-
return `<text font-size="${s}" xml:space="preserve" fill="#fff"><tspan x="75" y="${y}">${text}</tspan></text>`;
64-
})
65-
.join('\n');
22+
const renderSVG = ({ title }: { title: string }) => {
23+
const lines = balanceText(title, 30);
24+
25+
const titleText = `
26+
<text
27+
text-anchor="start"
28+
text-rendering="optimizeLegibility"
29+
font-size="${lines.length === 1 ? 80 : 64}"
30+
fill="#fff"
31+
x="75"
32+
y="500"
33+
>
34+
${lines.map((line, i) => `<tspan x="75" dy="${i === 0 ? '0' : '1.2em'}" >${encodeXML(line)}</tspan>`).join('')}
35+
</text>
36+
`;
6637

6738
return template.replace('<!-- titleText -->', titleText);
6839
};
6940

41+
function encodeXML(text: string): string {
42+
return text
43+
.replace(/&/g, '&amp;')
44+
.replace(/</g, '&lt;')
45+
.replace(/>/g, '&gt;')
46+
.replace(/"/g, '&quot;')
47+
.replace(/'/g, '&apos;');
48+
}
49+
50+
function balanceText(text: string, maxLen: number): string[] {
51+
const words = text.split(' ');
52+
if (words.join(' ').length <= maxLen) return [text];
53+
54+
let bestSplit = 0;
55+
let bestDiff = Number.POSITIVE_INFINITY;
56+
57+
for (let i = 0; i < words.length - 1; i++) {
58+
const line1 = words.slice(0, i + 1).join(' ');
59+
const line2 = words.slice(i + 1).join(' ');
60+
const diff = Math.abs(line1.length - line2.length);
61+
if (diff < bestDiff) {
62+
bestDiff = diff;
63+
bestSplit = i + 1;
64+
}
65+
}
66+
67+
return [words.slice(0, bestSplit).join(' '), words.slice(bestSplit).join(' ')];
68+
}
69+
7070
export const GET = async ({ params }: { params: { route: string } }) => {
7171
const pages = await getPages();
7272
const pageEntry = pages[params.route];
7373
if (!pageEntry) return new Response('Page not found', { status: 404 });
7474

75-
const svgBuffer = Buffer.from(
76-
renderSVG({
77-
// @ts-expect-error TODO type properly
78-
title: pageEntry.frontmatter.hero?.tagline ?? pageEntry.frontmatter.title,
79-
// @ts-expect-error TODO type properly
80-
description: pageEntry.frontmatter.description,
81-
})
82-
);
83-
const body = await sharp(svgBuffer).resize(1200, 630).png().toBuffer();
75+
// @ts-expect-error TODO type properly
76+
const title = pageEntry.frontmatter.hero?.tagline ?? pageEntry.frontmatter.title;
77+
const svgBuffer = Buffer.from(renderSVG({ title }));
78+
const body = await sharp(svgBuffer).resize(1200, 630).webp({ lossless: true }).toBuffer();
79+
8480
return new Response(body, {
8581
headers: {
82+
'Content-Type': 'image/webp',
8683
'Cache-Control': 'no-cache, no-store, must-revalidate',
8784
Pragma: 'no-cache',
8885
Expires: '0',

0 commit comments

Comments
 (0)