0
mirror of https://github.com/valentineus/popov.link.git synced 2025-07-03 08:00:26 +03:00

feat: implement Open Graph image generation and enhance configuration

- Added ogImages integration to generate Open Graph images for blog posts.
- Updated configuration to include Open Graph settings and default preview image.
- Refactored Head component to utilize new preview property for Open Graph meta tags.
- Enhanced blog post schema to include preview image for structured data representation.
- Introduced utility functions for creating Open Graph images with dynamic content.
This commit is contained in:
2025-06-14 19:25:16 +00:00
parent 3d0f485746
commit a81117972d
48 changed files with 1650 additions and 164 deletions

View File

@ -1,11 +1,12 @@
import { defineConfig } from "astro/config";
import { remarkReadingTime } from "./src/plugins/remarkReadingTime";
import ogImages from "./src/integrations/ogImages";
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: "https://popov.link",
output: "static",
integrations: [sitemap()],
integrations: [sitemap(), ogImages()],
build: {
inlineStylesheets: "always",
},

1569
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,15 +23,22 @@
"@astrojs/check": "^0.9.4",
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.4.1",
"@resvg/resvg-js": "^2.6.2",
"astro": "^5.9.0",
"autoprefixer": "^10.4.21",
"cssnano": "^7.0.7",
"cssnano-preset-advanced": "^7.0.7",
"dayjs": "^1.11.13",
"geist": "^1.4.2",
"globby": "^14.1.0",
"gray-matter": "^4.0.3",
"mdast-util-to-string": "^4.0.0",
"reading-time": "^1.5.0",
"sass": "^1.89.1",
"satori": "^0.15.2",
"satori-html": "^0.3.2",
"schema-dts": "^1.1.5",
"sharp": "^0.34.2",
"typescript": "^5"
},
"devDependencies": {

2
public/images/preview/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,16 +1,18 @@
---
import type { WithContext, Thing } from "schema-dts";
import JsonLd from "./JsonLd.astro";
import OpenGraph from "./OpenGraph.astro";
type Props = {
readonly description: string;
readonly title: string;
readonly preview: string;
readonly schema: WithContext<Thing>;
readonly title: string;
};
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { description, title, schema } = Astro.props;
const { description, preview, schema, title } = Astro.props;
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const previewUrl = new URL(preview, Astro.site);
---
<head>
@ -24,7 +26,7 @@ const { description, title, schema } = Astro.props;
<link href="/feed.xml" rel="alternate" title="RSS" type="application/atom+xml" />
<link href="/sitemap-index.xml" rel="sitemap" />
<link href={canonicalURL} rel="canonical" />
<link href={canonicalUrl} rel="canonical" />
<title>{title}</title>
@ -35,6 +37,18 @@ const { description, title, schema } = Astro.props;
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
<OpenGraph title={title} description={description} />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={previewUrl} />
<meta property="og:url" content={canonicalUrl} />
<!-- Twitter Cards -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={previewUrl} />
<JsonLd schema={schema} />
</head>

View File

@ -1,26 +0,0 @@
---
import { config } from "../config";
type Props = {
readonly description: string;
readonly title: string;
};
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { description, title } = Astro.props;
const image = new URL(config.posts.defaultImage, Astro.site).toString();
---
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:url" content={canonicalURL} />
<!-- Twitter Cards -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />

View File

@ -1,10 +1,29 @@
export const config = {
author: {
name: "Valentin Popov",
email: "valentin@popov.link",
url: "https://popov.link/",
sameAs: ["https://www.linkedin.com/in/valentineus/", "https://github.com/valentineus"],
},
posts: {
defaultImage: "/images/photo.png",
// Open Graph
og: {
color: {
bg: "#181818",
bgCode: "#3b3d42",
blossom: "#6da13f",
text: "#dee2e6",
},
defaultPreview: "/images/photo.png",
dimensions: {
height: 630,
width: 1200,
},
fonts: {
bold: "./src/assets/JetBrainsMono/JetBrainsMono-Bold.ttf",
regular: "./src/assets/JetBrainsMono/JetBrainsMono-Regular.ttf",
},
photo: "./public/images/photo.png",
website: "popov.link",
},
};

View File

@ -0,0 +1,47 @@
import type { AstroIntegration } from "astro";
import { createOgImage } from "../utils/createOgImage";
import { globby } from "globby";
import fs from "fs/promises";
import matter from "gray-matter";
import path from "path";
const postsDir = path.resolve("./src/content/blog");
const outDir = path.resolve("./public/images/preview");
export default function ogImageGenerator(): AstroIntegration {
return {
name: "og-images",
hooks: {
"astro:build:setup": async ({ logger }) => {
await fs.mkdir(outDir, { recursive: true });
const mdFiles = await globby("*.md", { cwd: postsDir });
logger.info(`${mdFiles.length} posts found`);
const results = await Promise.allSettled(
mdFiles.map(async (file) => {
const slug = file.replace(/\.md$/, "");
const content = await fs.readFile(path.join(postsDir, file), "utf-8");
const { data } = matter(content);
const png = await createOgImage(data.title, data.datePublished);
const outPath = path.join(outDir, `${slug}.png`);
await fs.writeFile(outPath, png);
logger.info(`OG image created: ${slug}`);
})
);
results.forEach((r) => {
if (r.status === "rejected") {
logger.error(`Error for ${r.reason.slug}: ${r.reason.message}`);
}
});
const failures = results.filter((r) => r.status === "rejected");
if (failures.length) {
throw new Error(`Failed to generate OG images for ${failures.length} posts`);
}
},
},
};
}

View File

@ -7,16 +7,17 @@ import "../scss/global.scss";
type Props = {
readonly description: string;
readonly title: string;
readonly lang: string;
readonly preview: string;
readonly schema: WithContext<Thing>;
readonly title: string;
};
const { title, description, lang, schema } = Astro.props;
const { description, lang, preview, schema, title } = Astro.props;
---
<html lang={lang}>
<Head title={title} description={description} schema={schema} />
<Head title={title} description={description} preview={preview} schema={schema} />
<body>
<main>

View File

@ -1,9 +1,11 @@
---
import { config } from "../config";
import Layout from "../layouts/BaseLayout.astro";
import pageSchema from "../utils/schemas/pageSchema";
const title = "404 — Page Not Found | Valentin Popov";
const description = "The page you're looking for doesn't exist!";
const preview = config.og.defaultPreview;
const lang = "en";
const schema = pageSchema({
@ -15,7 +17,7 @@ const schema = pageSchema({
});
---
<Layout title={title} description={description} lang={lang} schema={schema}>
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}>
<div style={{ "text-align": "center" }}>
<h1>404</h1>
<p><strong>Page not found</strong></p>

View File

@ -25,6 +25,7 @@ const { Content, remarkPluginFrontmatter } = await post.render();
const description = post.data.description;
const isBasedOn = post.data.basedOn;
const lang = post.data.lang;
const preview = `/images/preview/${post.slug}.png`;
const slug = post.slug;
const title = post.data.title;
@ -34,13 +35,14 @@ const formattedDate = dayjs(post.data.datePublished.toString()).format("MMMM DD,
const schema = blogPostSchema({
siteUrl: new URL("/", Astro.site).toString(),
title,
description,
slug,
datePublished,
dateModified,
lang,
datePublished,
description,
isBasedOn,
lang,
preview,
slug,
title,
});
---
@ -52,7 +54,7 @@ const schema = blogPostSchema({
}
</style>
<Layout title={title} description={description} lang={lang} schema={schema}>
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}>
<article>
<header>
<h1>{title}</h1>

View File

@ -1,9 +1,10 @@
---
import type { CollectionEntry } from "astro:content";
import { config } from "../../config";
import { getCollection } from "astro:content";
import blogSchema from "../../utils/schemas/blogSchema";
import Layout from "../../layouts/BaseLayout.astro";
import PostElement from "../../components/PostElement.astro";
import blogSchema from "../../utils/schemas/blogSchema";
import RSSIcon from "../../components/Icons/RSS.astro";
const posts = await getCollection("blog", ({ data }) => {
@ -25,6 +26,7 @@ const years = Object.keys(postsByYear).sort((a, b) => Number(b) - Number(a));
const title = "Valentin Popov's Blog | Software Development, Leadership & Open-Source";
const description = "Explore Valentin Popov's blog on software development, tech leadership, and open-source experiments. Stay updated with in-depth tutorials and expert insights.";
const preview = config.og.defaultPreview;
const lang = "en";
const schema = blogSchema({
@ -34,7 +36,7 @@ const schema = blogSchema({
});
---
<Layout title={title} description={description} lang={lang} schema={schema}>
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}>
<section>
<h1>
Blog posts

View File

@ -1,12 +1,14 @@
---
import Layout from "../layouts/BaseLayout.astro";
import { config } from "../config";
import LatestPostsSection from "../components/Sections/LatestPosts.astro";
import Layout from "../layouts/BaseLayout.astro";
import pageSchema from "../utils/schemas/pageSchema";
import SocialLinksSection from "../components/Sections/SocialLinks.astro";
import WelcomeSection from "../components/Sections/Welcome.astro";
import pageSchema from "../utils/schemas/pageSchema";
const title = "Valentin Popov Software Developer & Team Lead | Tech Insights";
const description = "Blog by Valentin Popov — software developer and team lead writing about code, side projects, digital tools, and fun experiments.";
const preview = config.og.defaultPreview;
const lang = "en";
const schema = pageSchema({
@ -18,7 +20,7 @@ const schema = pageSchema({
});
---
<Layout title={title} description={description} lang={lang} schema={schema}>
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}>
<WelcomeSection />
<SocialLinksSection />
<LatestPostsSection />

View File

@ -0,0 +1,52 @@
import { config } from "../config";
import { html } from "satori-html";
import { resources } from "./ogResources";
import { Resvg } from "@resvg/resvg-js";
import dayjs from "dayjs";
import satori from "satori";
export async function createOgImage(title: string, datePublished: Date): Promise<Buffer> {
const formattedDate = dayjs(datePublished).format("MMMM DD, YYYY");
const markup = await satori(
html(`
<div tw="flex flex-col w-full h-full" style="background-color: ${config.og.color.bg}">
<div tw="flex flex-col w-full h-4/5 p-10 justify-center">
<div tw="text-2xl mb-6" style="color: ${config.og.color.text}">${formattedDate}</div>
<div tw="flex text-6xl w-full font-bold" style="color: ${config.og.color.text}">${title}</div>
</div>
<div tw="w-full h-1/5 flex p-10 items-center justify-between text-2xl" style="border-top: 1px solid ${config.og.color.bgCode}">
<div tw="flex items-center">
<span tw="ml-3" style="color: ${config.og.color.text}">${config.og.website.toLocaleUpperCase()}</span>
</div>
<div tw="flex items-center">
<img src="${resources.photoBase64}" tw="w-15 h-15 rounded-full" />
<div tw="flex flex-col ml-4">
<span style="color: ${config.og.color.text}">${config.author.name}</span>
<span style="color: ${config.og.color.blossom}">${config.author.email}</span>
</div>
</div>
</div>
</div>
`),
{
width: config.og.dimensions.width,
height: config.og.dimensions.height,
fonts: [
{
name: "Inter",
data: resources.fonts.regular,
weight: 400,
},
{
name: "Inter",
data: resources.fonts.bold,
weight: 700,
},
],
}
);
const image = new Resvg(markup, { fitTo: { mode: "width", value: config.og.dimensions.width } });
return image.render().asPng();
}

15
src/utils/ogResources.ts Normal file
View File

@ -0,0 +1,15 @@
import { config } from "../config";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
export const resources = {
fonts: {
regular: await fs.readFile(path.resolve(config.og.fonts.regular)),
bold: await fs.readFile(path.resolve(config.og.fonts.bold)),
},
photoBase64: await (async () => {
const buf = await fs.readFile(path.resolve(config.og.photo));
return "data:image/png;base64," + (await sharp(buf).resize(120, 120).png({ quality: 95 }).toBuffer()).toString("base64");
})(),
};

View File

@ -7,18 +7,19 @@ export type BlogPostSchemaParams = {
readonly description: string;
readonly isBasedOn?: string;
readonly lang: string;
readonly preview: string;
readonly siteUrl: string;
readonly slug: string;
readonly title: string;
};
export default ({ siteUrl, slug, title, description, datePublished, dateModified, lang, isBasedOn }: BlogPostSchemaParams): WithContext<BlogPosting> => ({
export default ({ siteUrl, slug, title, description, preview, datePublished, dateModified, lang, isBasedOn }: BlogPostSchemaParams): WithContext<BlogPosting> => ({
"@context": "https://schema.org",
"@type": "BlogPosting",
"url": new URL(`/blog/${slug}`, siteUrl).toString(),
"headline": title,
"description": description,
"image": new URL(config.posts.defaultImage, siteUrl).toString(),
"image": new URL(preview, siteUrl).toString(),
"datePublished": datePublished,
"dateModified": dateModified,
"inLanguage": lang,