refactor: update schema handling and improve SEO metadata
All checks were successful
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.
This commit is contained in:
2026-04-22 16:11:58 +00:00
parent 41bb309966
commit 5e818d804d
14 changed files with 227 additions and 101 deletions

View File

@@ -1,15 +1,16 @@
--- ---
import type { WithContext, Thing } from "schema-dts"; import type { Thing } from "schema-dts";
import JsonLd from "./JsonLd.astro"; import JsonLd from "./JsonLd.astro";
type Props = { type Props = {
readonly description: string; readonly description: string;
readonly preview: string; readonly preview: string;
readonly schema: WithContext<Thing>; readonly robots?: string;
readonly schema: Thing[];
readonly title: string; readonly title: string;
}; };
const { description, preview, schema, title } = Astro.props; const { description, preview, 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);
@@ -21,7 +22,7 @@ const previewUrl = new URL(preview, Astro.site);
<meta http-equiv="content-type" content="text/html; charset=utf-8" /> <meta http-equiv="content-type" content="text/html; charset=utf-8" />
<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="viewport" content="width=device-width, initial-scale=1" />
<link href="/feed.xml" rel="alternate" title="RSS" type="application/atom+xml" /> <link href="/feed.xml" rel="alternate" title="RSS" type="application/atom+xml" />

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

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";
@@ -9,15 +9,16 @@ type Props = {
readonly description: string; readonly description: string;
readonly lang: string; readonly lang: string;
readonly preview: string; readonly preview: string;
readonly schema: WithContext<Thing>; readonly robots?: string;
readonly schema: Thing[];
readonly title: string; readonly title: string;
}; };
const { description, lang, preview, schema, title } = Astro.props; const { description, lang, preview, 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} />
<body> <body>
<main> <main>

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>

View File

@@ -1,9 +1,13 @@
--- ---
import { type CollectionEntry, getCollection, render } from "astro:content"; import { type CollectionEntry, getCollection, render } from "astro:content";
import Comments from "../../components/Comments.astro";
import Layout from "../../layouts/BaseLayout.astro";
import blogPostSchema from "../../utils/schemas/blogPostSchema"; import blogPostSchema from "../../utils/schemas/blogPostSchema";
import breadcrumbSchema from "../../utils/schemas/breadcrumbSchema";
import Comments from "../../components/Comments.astro";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Layout from "../../layouts/BaseLayout.astro";
import personSchema from "../../utils/schemas/personSchema";
import websiteSchema from "../../utils/schemas/websiteSchema";
import { config } from "../../config";
type Props = CollectionEntry<"blog">; type Props = CollectionEntry<"blog">;
@@ -33,17 +37,31 @@ const dateModified = post.data.dateModified?.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,
}),
breadcrumbSchema({
siteUrl,
items: [
{ name: "Home", url: "/" },
{ name: "Blog", url: "/blog/" },
{ name: title, url: `/blog/${slug}` },
],
}),
];
--- ---
<style lang="scss"> <style lang="scss">

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

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

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 }),
}); };
};

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.id}`, 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,
})),
},
};
};

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
src/utils/schemas/ids.ts Normal file
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();

View File

@@ -1,23 +1,36 @@
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 mainEntityId?: 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", mainEntityId }: 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": mainEntityId ?? personId(siteUrl) },
};
}
return base;
};

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,
});

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,
});