Compare commits

...

31 Commits

Author SHA1 Message Date
renovate[bot] 318d6505da chore(deps): update dependency sass to v1.100.0
Test / npm test (pull_request) Successful in 28s
RenovateBot / renovate (push) Successful in 25s
Test / npm test (push) Successful in 30s
2026-05-23 00:05:52 +00:00
renovate[bot] 2f5d91fa06 chore(deps): update dependency astro to v6.3.7
Test / npm test (pull_request) Successful in 27s
RenovateBot / renovate (push) Successful in 33s
Test / npm test (push) Successful in 28s
2026-05-22 00:03:42 +00:00
renovate[bot] 2531ca5a83 chore(deps): update all digest updates
Test / npm test (pull_request) Successful in 28s
RenovateBot / renovate (push) Successful in 24s
Test / npm test (push) Successful in 27s
2026-05-21 00:05:43 +00:00
renovate[bot] c149fad351 chore(deps): update dependency astro to v6.3.5
Test / npm test (pull_request) Successful in 28s
RenovateBot / renovate (push) Successful in 24s
Test / npm test (push) Successful in 28s
2026-05-19 00:03:20 +00:00
renovate[bot] cc76ccac3c chore(deps): update dependency astro to v6.3.3
Test / npm test (pull_request) Successful in 29s
RenovateBot / renovate (push) Successful in 23s
Test / npm test (push) Successful in 28s
2026-05-15 00:03:09 +00:00
renovate[bot] 08275901a0 chore(deps): update all digest updates
Test / npm test (pull_request) Successful in 28s
RenovateBot / renovate (push) Successful in 23s
Test / npm test (push) Successful in 28s
2026-05-14 00:05:26 +00:00
renovate[bot] bdabc31a28 chore(deps): update all digest updates
Test / npm test (pull_request) Successful in 27s
RenovateBot / renovate (push) Successful in 22s
Test / npm test (push) Successful in 27s
2026-05-09 00:03:37 +00:00
Valentin Popov a33bd37e12 Merge pull request 'fix(deps): update dependency cssnano-preset-advanced to v8' (!47) from renovate/cssnano-preset-advanced-8.x into master
RenovateBot / renovate (push) Successful in 27s
Test / npm test (push) Successful in 32s
Reviewed-on: #47
2026-05-08 18:13:06 +04:00
renovate[bot] b5bd2b999e fix(deps): update dependency cssnano-preset-advanced to v8
Test / npm test (push) Successful in 33s
Test / npm test (pull_request) Successful in 30s
2026-05-08 13:09:32 +00:00
renovate[bot] 520142e952 chore(deps): update dependency astro to v6.3.1
Test / npm test (pull_request) Successful in 28s
RenovateBot / renovate (push) Successful in 33s
Test / npm test (push) Successful in 29s
2026-05-08 13:08:40 +00:00
Valentin Popov 4c1e02600b Merge pull request 'fix(deps): update dependency cssnano to v8' (!45) from renovate/cssnano-8.x into master
RenovateBot / renovate (push) Successful in 3m49s
Test / npm test (push) Successful in 28s
Reviewed-on: #45
2026-05-08 17:05:50 +04:00
renovate[bot] 783ea5d6c3 fix(deps): update dependency cssnano to v8
Test / npm test (push) Successful in 32s
Test / npm test (pull_request) Successful in 29s
2026-05-07 00:04:15 +00:00
renovate[bot] cc48ac6b9a chore(deps): update npm to v11.14.0
Test / npm test (pull_request) Successful in 28s
RenovateBot / renovate (push) Successful in 31s
Test / npm test (push) Successful in 28s
2026-05-07 00:03:46 +00:00
renovate[bot] d0bf21d41d chore(deps): update all digest updates
Test / npm test (pull_request) Successful in 27s
RenovateBot / renovate (push) Successful in 23s
Test / npm test (push) Successful in 27s
2026-05-05 00:04:40 +00:00
renovate[bot] f504154f07 chore(deps): update all digest updates to v7.1.8
Test / npm test (pull_request) Successful in 34s
RenovateBot / renovate (push) Successful in 24s
Test / npm test (push) Successful in 34s
2026-05-03 00:01:30 +00:00
renovate[bot] 154533969e chore(deps): update dependency astro to v6.2.1
Test / npm test (pull_request) Successful in 34s
RenovateBot / renovate (push) Successful in 25s
Test / npm test (push) Successful in 34s
2026-05-01 00:02:03 +00:00
renovate[bot] b61f7b968a chore(deps): update all digest updates
Test / npm test (pull_request) Successful in 33s
RenovateBot / renovate (push) Successful in 25s
Test / npm test (push) Successful in 33s
2026-04-29 00:02:54 +00:00
renovate[bot] 7013ec75b8 chore(deps): update npm to v11.13.0
Test / npm test (pull_request) Successful in 35s
Test / npm test (push) Successful in 34s
RenovateBot / renovate (push) Successful in 26s
2026-04-23 00:01:42 +00:00
Valentin Popov 9a0746a471 feat: add RSS feed generation and update package metadata
Test / npm test (push) Successful in 37s
RenovateBot / renovate (push) Successful in 1m25s
- Implemented a new RSS feed generation feature in src/pages/feed.xml.ts, allowing users to follow blog updates.
- Updated package.json and package-lock.json to include license information and new type definitions for markdown-it and sanitize-html.
- Refactored createOgImage function to return Uint8Array instead of Buffer for better compatibility.
- Simplified pageSchema by removing the optional mainEntityId parameter for cleaner schema generation.
2026-04-22 18:53:50 +00:00
Valentin Popov a6efbdc3ab refactor: remove unused ImportMetaEnv interface from env.d.ts 2026-04-22 17:57:27 +00:00
Valentin Popov 933d6874b1 feat: enhance blog and SEO features with new plugins and metadata
- Introduced rehypeLazyImages plugin for lazy loading images in blog posts.
- Updated sitemap integration to include last modified dates for blog posts.
- Enhanced Head and BaseLayout components to support additional Open Graph metadata.
- Improved RSS feed generation with sanitized content and author information.
- Updated manifest.json for a darker theme and standalone display mode.
- Added support for language-specific attributes in various components.
- Refactored blog post handling to include modified and published timestamps.
2026-04-22 17:53:21 +00:00
Valentin Popov 5e818d804d refactor: update schema handling and improve SEO metadata
RenovateBot / renovate (push) Successful in 26s
Test / npm test (push) Successful in 46s
- Changed schema type from WithContext<Thing> to Thing[] in Head, BaseLayout, and JsonLd components for better flexibility.
- Added optional robots meta tag to Head component.
- Updated JSON-LD generation in JsonLd component to include a structured payload.
- Enhanced page and blog schemas to support breadcrumb and person schemas for improved SEO.
- Introduced new utility schemas for website and person to streamline schema generation.
- Refactored existing schemas to align with the new structure and ensure consistency across components.
2026-04-22 16:11:58 +00:00
renovate[bot] 41bb309966 chore(deps): update all digest updates to v7.1.7
Test / npm test (pull_request) Successful in 44s
Test / npm test (push) Successful in 45s
RenovateBot / renovate (push) Successful in 29s
2026-04-21 00:01:43 +00:00
renovate[bot] 08bb7b735b chore(deps): update dependency astro to v6.1.8
Test / npm test (pull_request) Successful in 47s
Test / npm test (push) Successful in 45s
RenovateBot / renovate (push) Successful in 56s
2026-04-19 00:01:48 +00:00
renovate[bot] a94a80ca38 chore(deps): update all digest updates
Test / npm test (pull_request) Successful in 45s
Test / npm test (push) Successful in 45s
RenovateBot / renovate (push) Successful in 1m22s
2026-04-16 00:02:23 +00:00
renovate[bot] 4d4ac9aa4b chore(deps): update all digest updates
Test / npm test (pull_request) Successful in 45s
Test / npm test (push) Successful in 45s
RenovateBot / renovate (push) Successful in 2m42s
2026-04-14 00:02:00 +00:00
renovate[bot] 2e83d14de1 chore(deps): update dependency prettier to v3.8.2
Test / npm test (pull_request) Successful in 45s
Test / npm test (push) Successful in 45s
RenovateBot / renovate (push) Successful in 1m24s
2026-04-11 00:01:57 +00:00
renovate[bot] d92a0842af chore(deps): update dependency astro to v6.1.5
Test / npm test (pull_request) Successful in 46s
Test / npm test (push) Successful in 48s
RenovateBot / renovate (push) Successful in 50s
2026-04-09 08:53:15 +00:00
Valentin Popov 994fb09d05 Merge pull request 'fix(deps): update dependency astro to v6' (!21) from renovate/major-astro-monorepo into master
Test / npm test (push) Has been cancelled
RenovateBot / renovate (push) Has been cancelled
Reviewed-on: #21
2026-04-09 12:52:31 +04:00
Valentin Popov f90592d8a1 feat: migrated to Astro 6
Test / npm test (push) Successful in 54s
Test / npm test (pull_request) Successful in 46s
2026-04-09 08:47:37 +00:00
renovate[bot] 12aa763b05 fix(deps): update dependency astro to v6
Test / npm test (push) Failing after 43s
Test / npm test (pull_request) Failing after 41s
2026-03-31 00:05:58 +00:00
28 changed files with 1937 additions and 2296 deletions
+39 -2
View File
@@ -1,17 +1,54 @@
import { defineConfig } from "astro/config";
import { globbySync } from "globby";
import fs from "node:fs";
import matter from "gray-matter";
import path from "node:path";
import rehypeExternalLinks from "rehype-external-links";
import sitemap from "@astrojs/sitemap";
import { remarkReadingTime } from "./src/plugins/remarkReadingTime";
import ogImages from "./src/integrations/ogImages";
import sitemap from "@astrojs/sitemap";
import rehypeLazyImages from "./src/plugins/rehypeLazyImages";
const blogDir = path.resolve("./src/content/blog");
const lastmodBySlug = Object.fromEntries(
globbySync("*.md", { cwd: blogDir }).map((file) => {
const slug = file.replace(/\.md$/, "");
const { data } = matter(fs.readFileSync(path.join(blogDir, file), "utf8"));
const date = data.dateModified ?? data.datePublished;
return [slug, new Date(date).toISOString()];
})
);
const buildLastmod = new Date().toISOString();
export default defineConfig({
site: "https://popov.link",
output: "static",
integrations: [sitemap(), ogImages()],
integrations: [
sitemap({
serialize(item) {
const url = new URL(item.url);
const match = url.pathname.match(/^\/blog\/([^/]+)\/?$/);
if (match && lastmodBySlug[match[1]]) {
item.lastmod = lastmodBySlug[match[1]];
} else {
item.lastmod = buildLastmod;
}
return item;
},
}),
ogImages(),
],
build: {
inlineStylesheets: "always",
},
markdown: {
remarkPlugins: [remarkReadingTime],
rehypePlugins: [[rehypeExternalLinks, { target: "_blank", rel: ["noopener", "noreferrer"] }], rehypeLazyImages],
shikiConfig: {
theme: "vitesse-dark",
},
+1539 -2122
View File
File diff suppressed because it is too large Load Diff
+13 -8
View File
@@ -3,11 +3,11 @@
"type": "module",
"version": "2025.01.24",
"private": true,
"packageManager": "npm@11.12.1",
"license": "MIT",
"packageManager": "npm@11.15.0",
"browserslist": [
">0.2%",
"not dead",
"IE 11"
"not dead"
],
"scripts": {
"format": "prettier --write .",
@@ -24,24 +24,29 @@
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.4.1",
"@resvg/resvg-js": "^2.6.2",
"astro": "^5.9.0",
"astro": "^6.0.0",
"autoprefixer": "^10.4.21",
"cssnano": "^7.0.7",
"cssnano-preset-advanced": "^7.0.7",
"cssnano": "^8.0.0",
"cssnano-preset-advanced": "^8.0.0",
"dayjs": "^1.11.13",
"geist": "^1.4.2",
"globby": "^16.0.0",
"gray-matter": "^4.0.3",
"markdown-it": "^14.1.1",
"mdast-util-to-string": "^4.0.0",
"reading-time": "^1.5.0",
"rehype-external-links": "^3.0.0",
"sanitize-html": "^2.17.3",
"sass": "^1.89.1",
"satori": "^0.26.0",
"satori-html": "^0.3.2",
"schema-dts": "^2.0.0",
"sharp": "^0.34.2",
"typescript": "^5"
"typescript": "^5",
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.16.1",
"prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1"
}
+3 -3
View File
@@ -18,7 +18,7 @@
"type": "image/png"
}
],
"background_color": "#ffffff",
"theme_color": "#ffffff",
"display": "fullscreen"
"background_color": "#181818",
"theme_color": "#181818",
"display": "standalone"
}
+1 -1
View File
@@ -3,4 +3,4 @@ module.exports = {
gitAuthor: "renovate[bot] <renovatebot@noreply.localhost>",
optimizeForDisabled: true,
platform: "gitea",
};
};
+34 -10
View File
@@ -1,32 +1,44 @@
---
import type { WithContext, Thing } from "schema-dts";
import type { Thing } from "schema-dts";
import { config } from "../config";
import JsonLd from "./JsonLd.astro";
type Props = {
readonly description: string;
readonly lang: string;
readonly modifiedTime?: string;
readonly ogType?: "website" | "article";
readonly preview: string;
readonly schema: WithContext<Thing>;
readonly publishedTime?: string;
readonly robots?: string;
readonly schema: Thing[];
readonly title: string;
};
const { description, preview, schema, title } = Astro.props;
const { description, lang, modifiedTime, ogType = "website", preview, publishedTime, robots = "index, follow", schema, title } = Astro.props;
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const previewUrl = new URL(preview, Astro.site);
const ogLocale = lang === "ru" ? "ru_RU" : "en_US";
---
<head>
<!-- Meta Tags -->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<meta name="robots" content="index, follow" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content={robots} />
<meta name="author" content={config.author.name} />
<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={config.author.url} rel="author" />
<!-- hreflang -->
<link rel="alternate" hreflang={lang} href={canonicalUrl} />
<link rel="alternate" hreflang="x-default" href={canonicalUrl} />
<title>{title}</title>
@@ -35,20 +47,32 @@ const previewUrl = new URL(preview, Astro.site);
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#181818" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={config.og.website} />
<meta property="og:locale" content={ogLocale} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={previewUrl} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={previewUrl} />
<meta property="og:image:width" content={String(config.og.dimensions.width)} />
<meta property="og:image:height" content={String(config.og.dimensions.height)} />
<meta property="og:image:alt" content={title} />
{ogType === "article" && publishedTime && <meta property="article:published_time" content={publishedTime} />}
{ogType === "article" && modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
{ogType === "article" && <meta property="article:author" content={config.author.url} />}
<!-- Twitter Cards -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@valyaha" />
<meta name="twitter:creator" content="@valyaha" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={previewUrl} />
<meta name="twitter:image:alt" content={title} />
<JsonLd schema={schema} />
</head>
+2 -2
View File
@@ -10,7 +10,7 @@
<header>
<nav aria-label="Navigation">
<a href="/" lang="en" aria-label="Home">Home</a>
<a href="/blog/" lang="en" aria-label="Blog">Blog</a>
<a href="/" lang="en">Home</a>
<a href="/blog/" lang="en">Blog</a>
</nav>
</header>
+9 -3
View File
@@ -1,12 +1,18 @@
---
import type { WithContext, Thing } from "schema-dts";
import type { Thing } from "schema-dts";
type Props = {
readonly schema: WithContext<Thing>;
readonly schema: Thing[];
};
const { schema } = Astro.props;
const json = JSON.stringify(schema);
const payload = {
"@context": "https://schema.org",
"@graph": schema,
};
const json = JSON.stringify(payload);
---
<!-- JSON-LD -->
+4 -4
View File
@@ -1,5 +1,5 @@
---
import { type CollectionEntry } from "astro:content";
import { type CollectionEntry, render } from "astro:content";
import dayjs from "dayjs";
type Props = {
@@ -7,7 +7,7 @@ type Props = {
};
const { post } = Astro.props;
const { remarkPluginFrontmatter } = await post.render();
const { remarkPluginFrontmatter } = await render(post);
const formattedDate = dayjs(post.data.datePublished.toString()).format("MMMM DD, YYYY");
const datePublished = post.data.datePublished.toISOString();
@@ -28,10 +28,10 @@ const datePublished = post.data.datePublished.toISOString();
<li>
<article>
<a href={`/blog/${post.slug}`} lang={post.data.lang}>{post.data.title}</a>
<a href={`/blog/${post.id}`} lang={post.data.lang}>{post.data.title}</a>
<div>
<small>
<time datetime={datePublished} lang="en">{formattedDate}</time>
<time datetime={datePublished} lang={post.data.lang}>{formattedDate}</time>
<span>•</span>
<span>{remarkPluginFrontmatter.minutesRead}</span>
</small>
+2 -2
View File
@@ -27,12 +27,12 @@ const latestPosts = posts.slice(0, 5);
{
latestPosts.map((post) => (
<li>
<a href={`/blog/${post.slug}`} lang={post.data.lang}>
<a href={`/blog/${post.id}`} lang={post.data.lang}>
{post.data.title}
</a>
<small>
<time datetime={post.data.datePublished.toISOString()} lang="en">
<time datetime={post.data.datePublished.toISOString()} lang={post.data.lang}>
{dayjs(post.data.datePublished.toString()).format("MMMM DD, YYYY")}
</time>
</small>
@@ -1,7 +1,9 @@
import { defineCollection, z } from "astro:content";
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
type: "content",
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
basedOn: z.optional(z.string()),
dateModified: z.coerce.date(),
-8
View File
@@ -1,9 +1 @@
/// <reference path="../.astro/types.d.ts" />
interface ImportMetaEnv {
readonly DEFAULT_TITLE: string;
readonly DEFAULT_DESCRIPTION: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+8 -4
View File
@@ -1,5 +1,5 @@
---
import type { WithContext, Thing } from "schema-dts";
import type { Thing } from "schema-dts";
import Analytics from "../components/Analytics.astro";
import Head from "../components/Head.astro";
import Header from "../components/Header.astro";
@@ -8,16 +8,20 @@ import "../scss/global.scss";
type Props = {
readonly description: string;
readonly lang: string;
readonly modifiedTime?: string;
readonly ogType?: "website" | "article";
readonly preview: string;
readonly schema: WithContext<Thing>;
readonly publishedTime?: string;
readonly robots?: string;
readonly schema: Thing[];
readonly title: string;
};
const { description, lang, preview, schema, title } = Astro.props;
const { description, lang, modifiedTime, ogType, preview, publishedTime, robots, schema, title } = Astro.props;
---
<html lang={lang}>
<Head title={title} description={description} preview={preview} schema={schema} />
<Head title={title} description={description} preview={preview} robots={robots} schema={schema} lang={lang} ogType={ogType} publishedTime={publishedTime} modifiedTime={modifiedTime} />
<body>
<main>
+12 -8
View File
@@ -8,16 +8,20 @@ const description = "The page you're looking for doesn't exist!";
const preview = config.og.defaultPreview;
const lang = "en";
const schema = pageSchema({
siteUrl: new URL("/", Astro.site).toString(),
page: "/404",
title,
description,
lang,
});
const siteUrl = new URL("/", Astro.site).toString();
const schema = [
pageSchema({
siteUrl,
page: "/404",
title,
description,
lang,
}),
];
---
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}>
<Layout title={title} description={description} preview={preview} lang={lang} robots="noindex, follow" schema={schema}>
<div style={{ "text-align": "center" }}>
<h1>404</h1>
<p><strong>Page not found</strong></p>
+41 -22
View File
@@ -1,9 +1,13 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import { type CollectionEntry, getCollection, render } from "astro:content";
import dayjs from "dayjs";
import blogPostSchema from "../../utils/schemas/blogPostSchema";
import breadcrumbSchema from "../../utils/schemas/breadcrumbSchema";
import Comments from "../../components/Comments.astro";
import Layout from "../../layouts/BaseLayout.astro";
import blogPostSchema from "../../utils/schemas/blogPostSchema";
import dayjs from "dayjs";
import personSchema from "../../utils/schemas/personSchema";
import websiteSchema from "../../utils/schemas/websiteSchema";
import { config } from "../../config";
type Props = CollectionEntry<"blog">;
@@ -13,37 +17,52 @@ export async function getStaticPaths() {
});
return posts.map((post) => ({
params: { slug: post.slug },
params: { slug: post.id },
props: post,
}));
}
const post = Astro.props;
const { Content, remarkPluginFrontmatter } = await post.render();
const { Content, remarkPluginFrontmatter } = await render(post);
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;
const preview = `/images/preview/${post.id}.png`;
const slug = post.id;
const headline = post.data.title;
const title = `${post.data.title} | Valentin Popov`;
const dateModified = post.data.dateModified?.toISOString();
const dateModified = (post.data.dateModified ?? post.data.datePublished).toISOString();
const datePublished = post.data.datePublished.toISOString();
const formattedDate = dayjs(post.data.datePublished.toString()).format("MMMM DD, YYYY");
const schema = blogPostSchema({
siteUrl: new URL("/", Astro.site).toString(),
dateModified,
datePublished,
description,
isBasedOn,
lang,
preview,
slug,
title,
});
const siteUrl = new URL("/", Astro.site).toString();
const schema = [
websiteSchema({ siteUrl, name: config.og.website, description, lang }),
personSchema({ siteUrl }),
blogPostSchema({
siteUrl,
dateModified,
datePublished,
description,
isBasedOn,
lang,
preview,
slug,
title: headline,
}),
breadcrumbSchema({
siteUrl,
items: [
{ name: "Home", url: "/" },
{ name: "Blog", url: "/blog/" },
{ name: headline, url: `/blog/${slug}` },
],
}),
];
---
<style lang="scss">
@@ -54,10 +73,10 @@ const schema = blogPostSchema({
}
</style>
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}>
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema} ogType="article" publishedTime={datePublished} modifiedTime={dateModified}>
<article>
<header>
<h1>{title}</h1>
<h1>{headline}</h1>
<p>
<small>
+15 -5
View File
@@ -3,9 +3,11 @@ import type { CollectionEntry } from "astro:content";
import { config } from "../../config";
import { getCollection } from "astro:content";
import blogSchema from "../../utils/schemas/blogSchema";
import breadcrumbSchema from "../../utils/schemas/breadcrumbSchema";
import Layout from "../../layouts/BaseLayout.astro";
import PostElement from "../../components/PostElement.astro";
import RSSIcon from "../../components/Icons/RSS.astro";
import websiteSchema from "../../utils/schemas/websiteSchema";
const posts = await getCollection("blog", ({ data }) => {
return data.draft !== true;
@@ -29,11 +31,19 @@ const description = "Explore Valentin Popov's blog on software development, tech
const preview = config.og.defaultPreview;
const lang = "en";
const schema = blogSchema({
siteUrl: new URL("/", Astro.site).toString(),
title,
posts,
});
const siteUrl = new URL("/", Astro.site).toString();
const schema = [
websiteSchema({ siteUrl, name: config.og.website, description, lang }),
blogSchema({ siteUrl, title, description, lang, posts }),
breadcrumbSchema({
siteUrl,
items: [
{ name: "Home", url: "/" },
{ name: "Blog", url: "/blog/" },
],
}),
];
---
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}>
-25
View File
@@ -1,25 +0,0 @@
import { getCollection } from "astro:content";
import rss from "@astrojs/rss";
export async function GET(context) {
const title = "RSS Feed | Valentin Popov Blog";
const description = "Follow the latest posts from Valentin Popov via RSS.";
const posts = await getCollection("blog", ({ data }) => {
return data.draft !== true;
});
return rss({
customData: `<language>en</language>`,
description: description,
items: posts.map((post) => ({
customData: post.data.customData,
description: post.data.description,
link: `/blog/${post.slug}`,
pubDate: post.data.pubDate,
title: post.data.title,
})),
site: context.site,
title: title,
});
}
+48
View File
@@ -0,0 +1,48 @@
import type { APIContext } from "astro";
import { getCollection } from "astro:content";
import MarkdownIt from "markdown-it";
import rss from "@astrojs/rss";
import sanitizeHtml from "sanitize-html";
import { config } from "../config";
const parser = new MarkdownIt({ html: false, linkify: true });
export async function GET(context: APIContext) {
const title = "RSS Feed | Valentin Popov Blog";
const description = "Follow the latest posts from Valentin Popov via RSS.";
const posts = (await getCollection("blog", ({ data }) => data.draft !== true)).sort((a, b) => b.data.datePublished.getTime() - a.data.datePublished.getTime());
const feedUrl = new URL("/feed.xml", context.site).toString();
return rss({
title,
description,
site: context.site ?? config.author.url,
xmlns: {
atom: "http://www.w3.org/2005/Atom",
content: "http://purl.org/rss/1.0/modules/content/",
dc: "http://purl.org/dc/elements/1.1/",
},
customData: `<atom:link href="${feedUrl}" rel="self" type="application/rss+xml"/>`,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
link: `/blog/${post.id}/`,
pubDate: post.data.datePublished,
author: `${config.author.email} (${config.author.name})`,
content: sanitizeHtml(parser.render(post.body ?? ""), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img", "pre", "code", "span"]),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ["src", "alt", "title", "loading", "decoding"],
code: ["class"],
span: ["class"],
pre: ["class"],
a: ["href", "name", "target", "rel"],
},
}),
customData: `<dc:language>${post.data.lang}</dc:language>`,
})),
});
}
+5 -7
View File
@@ -3,21 +3,19 @@ import { config } from "../config";
import LatestPostsSection from "../components/Sections/LatestPosts.astro";
import Layout from "../layouts/BaseLayout.astro";
import pageSchema from "../utils/schemas/pageSchema";
import personSchema from "../utils/schemas/personSchema";
import SocialLinksSection from "../components/Sections/SocialLinks.astro";
import WelcomeSection from "../components/Sections/Welcome.astro";
import websiteSchema from "../utils/schemas/websiteSchema";
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({
siteUrl: new URL("/", Astro.site).toString(),
page: "/",
title,
description,
lang,
});
const siteUrl = new URL("/", Astro.site).toString();
const schema = [websiteSchema({ siteUrl, name: config.og.website, description, lang }), personSchema({ siteUrl }), pageSchema({ siteUrl, page: "/", title, description, lang, type: "ProfilePage" })];
---
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}>
+13
View File
@@ -0,0 +1,13 @@
import type { Element, Root } from "hast";
import { visit } from "unist-util-visit";
export default function rehypeLazyImages() {
return (tree: Root): void => {
visit(tree, "element", (node: Element) => {
if (node.tagName !== "img") return;
node.properties ??= {};
node.properties.loading ??= "lazy";
node.properties.decoding ??= "async";
});
};
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { Resvg } from "@resvg/resvg-js";
import dayjs from "dayjs";
import satori from "satori";
export async function createOgImage(title: string, datePublished: Date): Promise<Buffer> {
export async function createOgImage(title: string, datePublished: Date): Promise<Uint8Array> {
const formattedDate = dayjs(datePublished).format("MMMM DD, YYYY");
const markup = await satori(
+25 -24
View File
@@ -1,5 +1,5 @@
import type { WithContext, BlogPosting } from "schema-dts";
import { config } from "../../config";
import type { BlogPosting } from "schema-dts";
import { personId, websiteId } from "./ids";
export type BlogPostSchemaParams = {
readonly dateModified: string;
@@ -13,25 +13,26 @@ export type BlogPostSchemaParams = {
readonly title: string;
};
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(preview, siteUrl).toString(),
"datePublished": datePublished,
"dateModified": dateModified,
"inLanguage": lang,
"author": {
"@type": "Person",
"name": config.author.name,
"url": config.author.url,
"sameAs": config.author.sameAs,
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": new URL(`/blog/${slug}`, siteUrl).toString(),
},
...(isBasedOn && { isBasedOn: isBasedOn }),
});
export default ({ siteUrl, slug, title, description, preview, datePublished, dateModified, lang, isBasedOn }: BlogPostSchemaParams): BlogPosting => {
const url = new URL(`/blog/${slug}`, siteUrl).toString();
return {
"@type": "BlogPosting",
"@id": url,
"url": url,
"headline": title,
"description": description,
"image": new URL(preview, siteUrl).toString(),
"datePublished": datePublished,
"dateModified": dateModified,
"inLanguage": lang,
"author": { "@id": personId(siteUrl) },
"publisher": { "@id": personId(siteUrl) },
"isPartOf": { "@id": websiteId(siteUrl) },
"mainEntityOfPage": {
"@type": "WebPage",
"@id": url,
},
...(isBasedOn && { isBasedOn: isBasedOn }),
};
};
+28 -18
View File
@@ -1,26 +1,36 @@
import type { WithContext, CollectionPage } from "schema-dts";
import type { CollectionPage } from "schema-dts";
import type { CollectionEntry } from "astro:content";
import { websiteId } from "./ids";
export type BlogSchemaParams = {
readonly description: string;
readonly lang: string;
readonly posts: CollectionEntry<"blog">[];
readonly siteUrl: string;
readonly title: string;
};
export default ({ siteUrl, title, posts }: BlogSchemaParams): WithContext<CollectionPage> => ({
"@context": "https://schema.org",
"@type": "CollectionPage",
"url": new URL("/blog/", siteUrl).toString(),
"name": title,
"mainEntity": {
"@type": "ItemList",
"itemListOrder": "https://schema.org/ItemListOrderDescending",
"numberOfItems": posts.length,
"itemListElement": posts.map((post, index) => ({
"@type": "ListItem",
"position": index + 1,
"url": new URL(`/blog/${post.slug}`, siteUrl).toString(),
"name": post.data.title,
})),
},
});
export default ({ siteUrl, title, description, lang, posts }: BlogSchemaParams): CollectionPage => {
const url = new URL("/blog/", siteUrl).toString();
return {
"@type": "CollectionPage",
"@id": url,
"url": url,
"name": title,
"description": description,
"inLanguage": lang,
"isPartOf": { "@id": websiteId(siteUrl) },
"mainEntity": {
"@type": "ItemList",
"itemListOrder": "https://schema.org/ItemListOrderDescending",
"numberOfItems": posts.length,
"itemListElement": posts.map((post, index) => ({
"@type": "ListItem",
"position": index + 1,
"url": new URL(`/blog/${post.id}`, siteUrl).toString(),
"name": post.data.title,
})),
},
};
};
+21
View File
@@ -0,0 +1,21 @@
import type { BreadcrumbList } from "schema-dts";
export type BreadcrumbItem = {
readonly name: string;
readonly url: string;
};
export type BreadcrumbSchemaParams = {
readonly items: BreadcrumbItem[];
readonly siteUrl: string;
};
export default ({ items, siteUrl }: BreadcrumbSchemaParams): BreadcrumbList => ({
"@type": "BreadcrumbList",
"itemListElement": items.map((item, index) => ({
"@type": "ListItem",
"position": index + 1,
"name": item.name,
"item": new URL(item.url, siteUrl).toString(),
})),
});
+3
View File
@@ -0,0 +1,3 @@
export const websiteId = (siteUrl: string): string => new URL("#website", siteUrl).toString();
export const personId = (siteUrl: string): string => new URL("#person", siteUrl).toString();
+27 -15
View File
@@ -1,23 +1,35 @@
import type { WithContext, WebPage } from "schema-dts";
import type { ProfilePage, WebPage } from "schema-dts";
import { personId, websiteId } from "./ids";
export type WebsiteSchemaParams = {
readonly description: string;
readonly lang: string;
readonly page: string;
readonly siteUrl: string;
readonly title: string;
readonly lang: string;
readonly type?: "WebPage" | "ProfilePage";
};
export default ({ siteUrl, page, title, description, lang }: WebsiteSchemaParams): WithContext<WebPage> => ({
"@context": "https://schema.org",
"@type": "WebPage",
"@id": new URL(page, siteUrl).toString(),
"url": new URL(page, siteUrl).toString(),
"name": title,
"description": description,
"inLanguage": lang,
"mainEntity": {
"@type": "WebSite",
"@id": new URL("/", siteUrl).toString(),
},
});
export default ({ siteUrl, page, title, description, lang, type = "WebPage" }: WebsiteSchemaParams): WebPage | ProfilePage => {
const url = new URL(page, siteUrl).toString();
const base = {
"@type": type,
"@id": url,
"url": url,
"name": title,
"description": description,
"inLanguage": lang,
"isPartOf": { "@id": websiteId(siteUrl) },
} as const;
if (type === "ProfilePage") {
return {
...base,
"@type": "ProfilePage",
"mainEntity": { "@id": personId(siteUrl) },
};
}
return base;
};
+17
View File
@@ -0,0 +1,17 @@
import type { Person } from "schema-dts";
import { config } from "../../config";
import { personId } from "./ids";
export type PersonSchemaParams = {
readonly siteUrl: string;
};
export default ({ siteUrl }: PersonSchemaParams): Person => ({
"@type": "Person",
"@id": personId(siteUrl),
"name": config.author.name,
"url": config.author.url,
"email": config.author.email,
"image": new URL(config.og.defaultPreview, siteUrl).toString(),
"sameAs": config.author.sameAs,
});
+23
View File
@@ -0,0 +1,23 @@
import type { WebSite } from "schema-dts";
import { config } from "../../config";
import { personId, websiteId } from "./ids";
export type WebsiteSchemaParams = {
readonly description: string;
readonly lang: string;
readonly name: string;
readonly siteUrl: string;
};
export default ({ siteUrl, name, description, lang }: WebsiteSchemaParams): WebSite => ({
"@type": "WebSite",
"@id": websiteId(siteUrl),
"url": siteUrl,
"name": name,
"description": description,
"inLanguage": lang,
"publisher": { "@id": personId(siteUrl) },
"author": { "@id": personId(siteUrl) },
"copyrightHolder": { "@id": personId(siteUrl) },
"sameAs": config.author.sameAs,
});