Compare commits

...

33 Commits

Author SHA1 Message Date
renovate[bot] 80fb7a6f45 chore(deps): update dependency markdown-it to v14.2.0
Test / npm test (pull_request) Successful in 28s
RenovateBot / renovate (push) Successful in 23s
Test / npm test (push) Successful in 30s
2026-05-24 00:01:30 +00:00
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] 7dd43ae74e chore(deps): update dependency sass to v1.99.0
Test / npm test (pull_request) Successful in 47s
Test / npm test (push) Successful in 52s
RenovateBot / renovate (push) Successful in 1m9s
2026-04-03 00:02:06 +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 1957 additions and 2296 deletions
+39 -2
View File
@@ -1,17 +1,54 @@
import { defineConfig } from "astro/config"; 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 { remarkReadingTime } from "./src/plugins/remarkReadingTime";
import ogImages from "./src/integrations/ogImages"; 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({ export default defineConfig({
site: "https://popov.link", site: "https://popov.link",
output: "static", 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: { build: {
inlineStylesheets: "always", inlineStylesheets: "always",
}, },
markdown: { markdown: {
remarkPlugins: [remarkReadingTime], remarkPlugins: [remarkReadingTime],
rehypePlugins: [[rehypeExternalLinks, { target: "_blank", rel: ["noopener", "noreferrer"] }], rehypeLazyImages],
shikiConfig: { shikiConfig: {
theme: "vitesse-dark", theme: "vitesse-dark",
}, },
+1559 -2122
View File
File diff suppressed because it is too large Load Diff
+13 -8
View File
@@ -3,11 +3,11 @@
"type": "module", "type": "module",
"version": "2025.01.24", "version": "2025.01.24",
"private": true, "private": true,
"packageManager": "npm@11.12.1", "license": "MIT",
"packageManager": "npm@11.15.0",
"browserslist": [ "browserslist": [
">0.2%", ">0.2%",
"not dead", "not dead"
"IE 11"
], ],
"scripts": { "scripts": {
"format": "prettier --write .", "format": "prettier --write .",
@@ -24,24 +24,29 @@
"@astrojs/rss": "^4.0.12", "@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.4.1", "@astrojs/sitemap": "^3.4.1",
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"astro": "^5.9.0", "astro": "^6.0.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"cssnano": "^7.0.7", "cssnano": "^8.0.0",
"cssnano-preset-advanced": "^7.0.7", "cssnano-preset-advanced": "^8.0.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"geist": "^1.4.2",
"globby": "^16.0.0", "globby": "^16.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"markdown-it": "^14.1.1",
"mdast-util-to-string": "^4.0.0", "mdast-util-to-string": "^4.0.0",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-external-links": "^3.0.0",
"sanitize-html": "^2.17.3",
"sass": "^1.89.1", "sass": "^1.89.1",
"satori": "^0.26.0", "satori": "^0.26.0",
"satori-html": "^0.3.2", "satori-html": "^0.3.2",
"schema-dts": "^2.0.0", "schema-dts": "^2.0.0",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"typescript": "^5" "typescript": "^5",
"unist-util-visit": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.16.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1" "prettier-plugin-astro": "^0.14.1"
} }
+3 -3
View File
@@ -18,7 +18,7 @@
"type": "image/png" "type": "image/png"
} }
], ],
"background_color": "#ffffff", "background_color": "#181818",
"theme_color": "#ffffff", "theme_color": "#181818",
"display": "fullscreen" "display": "standalone"
} }
+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"; import JsonLd from "./JsonLd.astro";
type Props = { type Props = {
readonly description: string; readonly description: string;
readonly lang: string;
readonly modifiedTime?: string;
readonly ogType?: "website" | "article";
readonly preview: string; readonly preview: string;
readonly schema: WithContext<Thing>; readonly publishedTime?: string;
readonly robots?: string;
readonly schema: Thing[];
readonly title: string; 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 canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const previewUrl = new URL(preview, Astro.site); const previewUrl = new URL(preview, Astro.site);
const ogLocale = lang === "ru" ? "ru_RU" : "en_US";
--- ---
<head> <head>
<!-- Meta Tags --> <!-- Meta Tags -->
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta charset="utf-8" />
<meta http-equiv="content-type" content="text/html; charset=utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta name="robots" content="index, follow" /> <meta name="robots" content={robots} />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="author" content={config.author.name} />
<link href="/feed.xml" rel="alternate" title="RSS" type="application/atom+xml" /> <link href="/feed.xml" rel="alternate" title="RSS" type="application/atom+xml" />
<link href="/sitemap-index.xml" rel="sitemap" /> <link href="/sitemap-index.xml" rel="sitemap" />
<link href={canonicalUrl} rel="canonical" /> <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> <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="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#181818" />
<!-- Open Graph --> <!-- 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:title" content={title} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:image" content={previewUrl} />
<meta property="og:url" content={canonicalUrl} /> <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 --> <!-- Twitter Cards -->
<meta name="twitter:card" content="summary_large_image" /> <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:title" content={title} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<meta name="twitter:image" content={previewUrl} /> <meta name="twitter:image" content={previewUrl} />
<meta name="twitter:image:alt" content={title} />
<JsonLd schema={schema} /> <JsonLd schema={schema} />
</head> </head>
+2 -2
View File
@@ -10,7 +10,7 @@
<header> <header>
<nav aria-label="Navigation"> <nav aria-label="Navigation">
<a href="/" lang="en" aria-label="Home">Home</a> <a href="/" lang="en">Home</a>
<a href="/blog/" lang="en" aria-label="Blog">Blog</a> <a href="/blog/" lang="en">Blog</a>
</nav> </nav>
</header> </header>
+9 -3
View File
@@ -1,12 +1,18 @@
--- ---
import type { WithContext, Thing } from "schema-dts"; import type { Thing } from "schema-dts";
type Props = { type Props = {
readonly schema: WithContext<Thing>; readonly schema: Thing[];
}; };
const { schema } = Astro.props; const { schema } = Astro.props;
const json = JSON.stringify(schema);
const payload = {
"@context": "https://schema.org",
"@graph": schema,
};
const json = JSON.stringify(payload);
--- ---
<!-- JSON-LD --> <!-- 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"; import dayjs from "dayjs";
type Props = { type Props = {
@@ -7,7 +7,7 @@ type Props = {
}; };
const { post } = Astro.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 formattedDate = dayjs(post.data.datePublished.toString()).format("MMMM DD, YYYY");
const datePublished = post.data.datePublished.toISOString(); const datePublished = post.data.datePublished.toISOString();
@@ -28,10 +28,10 @@ const datePublished = post.data.datePublished.toISOString();
<li> <li>
<article> <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> <div>
<small> <small>
<time datetime={datePublished} lang="en">{formattedDate}</time> <time datetime={datePublished} lang={post.data.lang}>{formattedDate}</time>
<span>•</span> <span>•</span>
<span>{remarkPluginFrontmatter.minutesRead}</span> <span>{remarkPluginFrontmatter.minutesRead}</span>
</small> </small>
+2 -2
View File
@@ -27,12 +27,12 @@ const latestPosts = posts.slice(0, 5);
{ {
latestPosts.map((post) => ( latestPosts.map((post) => (
<li> <li>
<a href={`/blog/${post.slug}`} lang={post.data.lang}> <a href={`/blog/${post.id}`} lang={post.data.lang}>
{post.data.title} {post.data.title}
</a> </a>
<small> <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")} {dayjs(post.data.datePublished.toString()).format("MMMM DD, YYYY")}
</time> </time>
</small> </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({ const blog = defineCollection({
type: "content", loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({ schema: z.object({
basedOn: z.optional(z.string()), basedOn: z.optional(z.string()),
dateModified: z.coerce.date(), dateModified: z.coerce.date(),
-8
View File
@@ -1,9 +1 @@
/// <reference path="../.astro/types.d.ts" /> /// <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 Analytics from "../components/Analytics.astro";
import Head from "../components/Head.astro"; import Head from "../components/Head.astro";
import Header from "../components/Header.astro"; import Header from "../components/Header.astro";
@@ -8,16 +8,20 @@ import "../scss/global.scss";
type Props = { type Props = {
readonly description: string; readonly description: string;
readonly lang: string; readonly lang: string;
readonly modifiedTime?: string;
readonly ogType?: "website" | "article";
readonly preview: string; readonly preview: string;
readonly schema: WithContext<Thing>; readonly publishedTime?: string;
readonly robots?: string;
readonly schema: Thing[];
readonly title: string; 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}> <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> <body>
<main> <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 preview = config.og.defaultPreview;
const lang = "en"; const lang = "en";
const schema = pageSchema({ const siteUrl = new URL("/", Astro.site).toString();
siteUrl: new URL("/", Astro.site).toString(),
page: "/404", const schema = [
title, pageSchema({
description, siteUrl,
lang, 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" }}> <div style={{ "text-align": "center" }}>
<h1>404</h1> <h1>404</h1>
<p><strong>Page not found</strong></p> <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 Comments from "../../components/Comments.astro";
import Layout from "../../layouts/BaseLayout.astro"; import Layout from "../../layouts/BaseLayout.astro";
import blogPostSchema from "../../utils/schemas/blogPostSchema"; import personSchema from "../../utils/schemas/personSchema";
import dayjs from "dayjs"; import websiteSchema from "../../utils/schemas/websiteSchema";
import { config } from "../../config";
type Props = CollectionEntry<"blog">; type Props = CollectionEntry<"blog">;
@@ -13,37 +17,52 @@ export async function getStaticPaths() {
}); });
return posts.map((post) => ({ return posts.map((post) => ({
params: { slug: post.slug }, params: { slug: post.id },
props: post, props: post,
})); }));
} }
const post = Astro.props; const post = Astro.props;
const { Content, remarkPluginFrontmatter } = await post.render(); const { Content, remarkPluginFrontmatter } = await render(post);
const description = post.data.description; const description = post.data.description;
const isBasedOn = post.data.basedOn; const isBasedOn = post.data.basedOn;
const lang = post.data.lang; const lang = post.data.lang;
const preview = `/images/preview/${post.slug}.png`; const preview = `/images/preview/${post.id}.png`;
const slug = post.slug; const slug = post.id;
const title = post.data.title; 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 datePublished = post.data.datePublished.toISOString();
const formattedDate = dayjs(post.data.datePublished.toString()).format("MMMM DD, YYYY"); const formattedDate = dayjs(post.data.datePublished.toString()).format("MMMM DD, YYYY");
const schema = blogPostSchema({ const siteUrl = new URL("/", Astro.site).toString();
siteUrl: new URL("/", Astro.site).toString(),
dateModified, const schema = [
datePublished, websiteSchema({ siteUrl, name: config.og.website, description, lang }),
description, personSchema({ siteUrl }),
isBasedOn, blogPostSchema({
lang, siteUrl,
preview, dateModified,
slug, datePublished,
title, 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"> <style lang="scss">
@@ -54,10 +73,10 @@ const schema = blogPostSchema({
} }
</style> </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> <article>
<header> <header>
<h1>{title}</h1> <h1>{headline}</h1>
<p> <p>
<small> <small>
+15 -5
View File
@@ -3,9 +3,11 @@ import type { CollectionEntry } from "astro:content";
import { config } from "../../config"; import { config } from "../../config";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import blogSchema from "../../utils/schemas/blogSchema"; import blogSchema from "../../utils/schemas/blogSchema";
import breadcrumbSchema from "../../utils/schemas/breadcrumbSchema";
import Layout from "../../layouts/BaseLayout.astro"; import Layout from "../../layouts/BaseLayout.astro";
import PostElement from "../../components/PostElement.astro"; import PostElement from "../../components/PostElement.astro";
import RSSIcon from "../../components/Icons/RSS.astro"; import RSSIcon from "../../components/Icons/RSS.astro";
import websiteSchema from "../../utils/schemas/websiteSchema";
const posts = await getCollection("blog", ({ data }) => { const posts = await getCollection("blog", ({ data }) => {
return data.draft !== true; 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 preview = config.og.defaultPreview;
const lang = "en"; const lang = "en";
const schema = blogSchema({ const siteUrl = new URL("/", Astro.site).toString();
siteUrl: new URL("/", Astro.site).toString(),
title, const schema = [
posts, 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}> <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 LatestPostsSection from "../components/Sections/LatestPosts.astro";
import Layout from "../layouts/BaseLayout.astro"; import Layout from "../layouts/BaseLayout.astro";
import pageSchema from "../utils/schemas/pageSchema"; import pageSchema from "../utils/schemas/pageSchema";
import personSchema from "../utils/schemas/personSchema";
import SocialLinksSection from "../components/Sections/SocialLinks.astro"; import SocialLinksSection from "../components/Sections/SocialLinks.astro";
import WelcomeSection from "../components/Sections/Welcome.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 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 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 preview = config.og.defaultPreview;
const lang = "en"; const lang = "en";
const schema = pageSchema({ const siteUrl = new URL("/", Astro.site).toString();
siteUrl: new URL("/", Astro.site).toString(),
page: "/", const schema = [websiteSchema({ siteUrl, name: config.og.website, description, lang }), personSchema({ siteUrl }), pageSchema({ siteUrl, page: "/", title, description, lang, type: "ProfilePage" })];
title,
description,
lang,
});
--- ---
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}> <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 dayjs from "dayjs";
import satori from "satori"; 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 formattedDate = dayjs(datePublished).format("MMMM DD, YYYY");
const markup = await satori( const markup = await satori(
+25 -24
View File
@@ -1,5 +1,5 @@
import type { WithContext, BlogPosting } from "schema-dts"; import type { BlogPosting } from "schema-dts";
import { config } from "../../config"; import { personId, websiteId } from "./ids";
export type BlogPostSchemaParams = { export type BlogPostSchemaParams = {
readonly dateModified: string; readonly dateModified: string;
@@ -13,25 +13,26 @@ export type BlogPostSchemaParams = {
readonly title: string; readonly title: string;
}; };
export default ({ siteUrl, slug, title, description, preview, datePublished, dateModified, lang, isBasedOn }: BlogPostSchemaParams): WithContext<BlogPosting> => ({ export default ({ siteUrl, slug, title, description, preview, datePublished, dateModified, lang, isBasedOn }: BlogPostSchemaParams): BlogPosting => {
"@context": "https://schema.org", const url = new URL(`/blog/${slug}`, siteUrl).toString();
"@type": "BlogPosting",
"url": new URL(`/blog/${slug}`, siteUrl).toString(), return {
"headline": title, "@type": "BlogPosting",
"description": description, "@id": url,
"image": new URL(preview, siteUrl).toString(), "url": url,
"datePublished": datePublished, "headline": title,
"dateModified": dateModified, "description": description,
"inLanguage": lang, "image": new URL(preview, siteUrl).toString(),
"author": { "datePublished": datePublished,
"@type": "Person", "dateModified": dateModified,
"name": config.author.name, "inLanguage": lang,
"url": config.author.url, "author": { "@id": personId(siteUrl) },
"sameAs": config.author.sameAs, "publisher": { "@id": personId(siteUrl) },
}, "isPartOf": { "@id": websiteId(siteUrl) },
"mainEntityOfPage": { "mainEntityOfPage": {
"@type": "WebPage", "@type": "WebPage",
"@id": new URL(`/blog/${slug}`, siteUrl).toString(), "@id": url,
}, },
...(isBasedOn && { isBasedOn: isBasedOn }), ...(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 type { CollectionEntry } from "astro:content";
import { websiteId } from "./ids";
export type BlogSchemaParams = { export type BlogSchemaParams = {
readonly description: string;
readonly lang: string;
readonly posts: CollectionEntry<"blog">[]; readonly posts: CollectionEntry<"blog">[];
readonly siteUrl: string; readonly siteUrl: string;
readonly title: string; readonly title: string;
}; };
export default ({ siteUrl, title, posts }: BlogSchemaParams): WithContext<CollectionPage> => ({ export default ({ siteUrl, title, description, lang, posts }: BlogSchemaParams): CollectionPage => {
"@context": "https://schema.org", const url = new URL("/blog/", siteUrl).toString();
"@type": "CollectionPage",
"url": new URL("/blog/", siteUrl).toString(), return {
"name": title, "@type": "CollectionPage",
"mainEntity": { "@id": url,
"@type": "ItemList", "url": url,
"itemListOrder": "https://schema.org/ItemListOrderDescending", "name": title,
"numberOfItems": posts.length, "description": description,
"itemListElement": posts.map((post, index) => ({ "inLanguage": lang,
"@type": "ListItem", "isPartOf": { "@id": websiteId(siteUrl) },
"position": index + 1, "mainEntity": {
"url": new URL(`/blog/${post.slug}`, siteUrl).toString(), "@type": "ItemList",
"name": post.data.title, "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 = { export type WebsiteSchemaParams = {
readonly description: string; readonly description: string;
readonly lang: string;
readonly page: string; readonly page: string;
readonly siteUrl: string; readonly siteUrl: string;
readonly title: string; readonly title: string;
readonly lang: string; readonly type?: "WebPage" | "ProfilePage";
}; };
export default ({ siteUrl, page, title, description, lang }: WebsiteSchemaParams): WithContext<WebPage> => ({ export default ({ siteUrl, page, title, description, lang, type = "WebPage" }: WebsiteSchemaParams): WebPage | ProfilePage => {
"@context": "https://schema.org", const url = new URL(page, siteUrl).toString();
"@type": "WebPage",
"@id": new URL(page, siteUrl).toString(), const base = {
"url": new URL(page, siteUrl).toString(), "@type": type,
"name": title, "@id": url,
"description": description, "url": url,
"inLanguage": lang, "name": title,
"mainEntity": { "description": description,
"@type": "WebSite", "inLanguage": lang,
"@id": new URL("/", siteUrl).toString(), "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,
});