0
mirror of https://github.com/valentineus/popov.link.git synced 2025-07-20 15:18:48 +03:00

Compare commits

..

18 Commits

Author SHA1 Message Date
renovate[bot]
ae3176da79 fix(deps): update dependency astro to v5.12.0 (#68)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 22:28:10 +00:00
renovate[bot]
3bb4d50767 fix(deps): update dependency astro to v5.11.2 (#67)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-16 19:34:50 +00:00
renovate[bot]
dd33ec8e48 fix(deps): update all digest updates to v7.1.0 (#66)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-15 04:08:19 +00:00
renovate[bot]
4698e50cb7 fix(deps): update dependency astro to v5.11.1 (#65)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 16:47:09 +00:00
renovate[bot]
05a0579a91 chore(deps): update mcr.microsoft.com/devcontainers/javascript-node docker tag to v24 (#63) 2025-07-10 21:34:30 +04:00
renovate[bot]
80fcd46280 fix(deps): update dependency sharp to v0.34.3 (#62)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 16:29:23 +00:00
renovate[bot]
7634a2e325 fix(deps): update dependency astro to v5.11.0 (#61)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 16:35:07 +00:00
renovate[bot]
29385b1edf fix(deps): update dependency astro to v5.10.2 (#60)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 16:30:49 +00:00
renovate[bot]
ae7dac3099 chore(deps): update dependency prettier to v3.6.2 (#59)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-27 05:23:58 +00:00
renovate[bot]
d8a59ef4c4 chore(deps): update dependency prettier to v3.6.1 (#58)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 19:13:58 +00:00
renovate[bot]
4412049beb fix(deps): update dependency astro to v5.10.1 (#57)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 19:11:55 +00:00
renovate[bot]
c32aa4e773 chore(deps): update dependency prettier to v3.6.0 (#56)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 07:43:05 +00:00
renovate[bot]
c9d67d9210 fix(deps): update dependency astro to v5.10.0 (#55)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 19:52:42 +00:00
renovate[bot]
d99c901b42 fix(deps): update dependency astro to v5.9.4 (#54)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-17 17:06:39 +00:00
15fdeb46f1 chore: update README for consistency
- Removed duplicate line regarding project maintenance to streamline the README content.
2025-06-15 12:22:29 +00:00
15795a5337 chore: update GitHub Actions workflow for repository mirroring
- Modified the workflow trigger to include both push and pull request events on the master branch.
- Set permissions for the workflow to allow read access to contents.
2025-06-15 12:02:26 +00:00
bc4f65c1f6 chore: update README and add GitHub Actions workflow for repository mirroring
- Updated the README to change the read-only mirror link to the new URL.
- Added a GitHub Actions workflow for mirroring the repository to a remote Git repository.
2025-06-15 11:51:56 +00:00
a81117972d 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.
2025-06-14 19:25:16 +00:00
51 changed files with 1796 additions and 289 deletions

View File

@@ -1,5 +1,5 @@
{
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
"image": "mcr.microsoft.com/devcontainers/javascript-node:24",
"forwardPorts": [4321],
"portsAttributes": {
"4321": {

25
.github/workflows/mirror.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Mirror
on:
push:
branches: [master]
pull_request:
branches: [master]
permissions:
contents: read
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Mirror to remote repository
uses: yesolutions/mirror-action@master
with:
REMOTE: "https://git.popov.link/popov.link.git"
GIT_USERNAME: ${{ secrets.GIT_USERNAME }}
GIT_PASSWORD: ${{ secrets.GIT_PASSWORD }}

View File

@@ -41,9 +41,9 @@ npm run preview
## Project Info
- Maintained by [Valentin Popov](mailto:valentin@popov.link)
- Issues: [GitHub](https://github.com/valentineus/popov.link/issues)
- Read-only mirror: [code.popov.link](https://code.popov.link/valentineus/popov.link)
- Read-only mirror: [git.popov.link](https://git.popov.link/popov.link/)
- Maintained by [Valentin Popov](mailto:valentin@popov.link)
## Comments

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",
},

1809
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,