refactor(ui): enhance SEO metadata and structured data injection

This commit is contained in:
Prad Nukala
2026-06-30 14:28:48 -04:00
parent 0c4af424f8
commit 2f40cf3bce
10 changed files with 100 additions and 32 deletions
+19 -2
View File
@@ -1,17 +1,21 @@
---
import { WEBMENTION_PINGBACK, WEBMENTION_URL } from "astro:env/client";
import { siteConfig } from "@/site.config";
import { siteConfig, socialLinks } from "@/site.config";
import type { SiteMeta } from "@/types";
import "@/styles/global.css";
type Props = SiteMeta;
const { articleDate, description, ogImage, title } = Astro.props;
const { articleDate, description, keywords, ogImage, schema, title } = Astro.props;
const titleSeparator = "•";
const siteTitle = `${title} ${titleSeparator} ${siteConfig.title}`;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url).href;
const xLink = socialLinks.find((s) => s.friendlyName === "X")?.link;
const twitterHandle = xLink ? `@${xLink.split("/").pop()}` : undefined;
const schemas = schema ? (Array.isArray(schema) ? schema : [schema]) : [];
---
<meta charset="utf-8" />
@@ -38,8 +42,15 @@ const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url
{/* Primary Meta Tags */}
<meta content={siteTitle} name="title" />
<meta content={description} name="description" />
{keywords && keywords.length > 0 && <meta content={keywords.join(", ")} name="keywords" />}
<meta content={siteConfig.author} name="author" />
{/* Tell crawlers to index and allow full image/snippet/video previews */}
<meta
content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
name="robots"
/>
{/* Open Graph / Facebook */}
<meta content={articleDate ? "article" : "website"} property="og:type" />
<meta content={title} property="og:title" />
@@ -48,6 +59,7 @@ const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url
<meta content={siteConfig.title} property="og:site_name" />
<meta content={siteConfig.ogLocale} property="og:locale" />
<meta content={socialImageURL} property="og:image" />
<meta content={`${title} — ${siteConfig.title}`} property="og:image:alt" />
<meta content="1200" property="og:image:width" />
<meta content="630" property="og:image:height" />
{
@@ -65,6 +77,11 @@ const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url
<meta content={title} property="twitter:title" />
<meta content={description} property="twitter:description" />
<meta content={socialImageURL} property="twitter:image" />
{twitterHandle && <meta content={twitterHandle} property="twitter:creator" />}
{twitterHandle && <meta content={twitterHandle} property="twitter:site" />}
{/* Structured data (schema.org JSON-LD) */}
{schemas.map((s) => <script type="application/ld+json" is:inline set:html={JSON.stringify(s)} />)}
{/* Sitemap */}
<link href="/sitemap-index.xml" rel="sitemap" />
-20
View File
@@ -1,20 +0,0 @@
---
import { streamOrigin } from "@/site.config";
interface Props {
videoId: string;
title?: string;
}
const { videoId, title } = Astro.props;
---
<div class="aspect-video overflow-hidden rounded-md">
<iframe
class="h-full w-full border-0"
src={`${streamOrigin}/${videoId}/iframe`}
title={title}
loading="lazy"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowfullscreen></iframe>
</div>
+1 -1
View File
@@ -16,7 +16,7 @@ const { tags, class: className } = Astro.props;
<li>
<a
class="text-muted hover:text-link text-xs before:content-['#']"
href={`/tags/${slugifyTag(tag)}/`}
href={`/skills/${slugifyTag(tag)}/`}
>
{tag}
</a>
+19
View File
@@ -0,0 +1,19 @@
---
interface Props {
id: string;
title?: string;
}
const { id, title = "YouTube video player" } = Astro.props;
---
<div class="aspect-video overflow-hidden rounded-md">
<iframe
class="h-full w-full border-0"
src={`https://www.youtube-nocookie.com/embed/${id}`}
title={title}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen></iframe>
</div>
+2
View File
@@ -3,6 +3,7 @@ import { Image } from "astro:assets";
import type { CollectionEntry } from "astro:content";
import ExperienceTags from "@/components/ExperienceTags.astro";
import FormattedDate from "@/components/FormattedDate.astro";
import TagList from "@/components/TagList.astro";
interface Props {
content: CollectionEntry<"writing">;
@@ -58,3 +59,4 @@ const dateTimeOptions: Intl.DateTimeFormatOptions = {
</div>
)
}
<TagList tags={data.tags} class="mt-3" />
+15 -1
View File
@@ -9,13 +9,27 @@ type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
};
const { as: Tag = "div", post, withDesc = false } = Astro.props;
const { externalUrl, publisher } = post.data;
const href = externalUrl ?? `/writing/${post.id}/`;
---
<FormattedDate class="text-muted min-w-30 font-semibold" date={post.data.publishDate} />
<Tag>
{post.data.draft && <span class="text-red-500">(Draft) </span>}
<a class="cactus-link" href={`/writing/${post.id}/`}>
<a
class="cactus-link"
href={href}
{...externalUrl ? { rel: "noopener noreferrer", target: "_blank" } : {}}
>
{post.data.title}
</a>
{
externalUrl && (
<span class="text-muted text-xs whitespace-nowrap">
{" ↗ "}
{publisher ?? "External"}
</span>
)
}
</Tag>
{withDesc && <q class="line-clamp-3 italic">{post.data.description}</q>}
+2 -1
View File
@@ -11,6 +11,7 @@ const year = new Date().getFullYear();
{/* Row 1: site nav */}
<nav aria-labelledby="footer_links" class="flex flex-wrap justify-center gap-x-1 gap-y-1">
<p id="footer_links" class="sr-only">More on this site</p>
<a class="hover:text-global-text px-3 py-1 hover:underline" href="/skills/">Skills</a>
{
menuLinks.map((link) => (
<a class="hover:text-global-text px-3 py-1 hover:underline" href={link.path}>
@@ -28,7 +29,7 @@ const year = new Date().getFullYear();
<a
class="hover:text-link inline-block"
href={link}
rel={`noreferrer ${isWebmention ? "me authn" : ""}`}
rel={`me noreferrer ${isWebmention ? "authn" : ""}`}
target={link.startsWith("mailto:") ? undefined : "_blank"}
>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name={name} />
+9 -4
View File
@@ -1,7 +1,6 @@
---
import { type CollectionEntry, render } from "astro:content";
import { type CollectionEntry, getEntries, render } from "astro:content";
import type { HTMLTag, Polymorphic } from "astro/types";
import ExperienceTags from "@/components/ExperienceTags.astro";
import TagList from "@/components/TagList.astro";
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
@@ -13,6 +12,7 @@ const { as: Tag = "div", project, isPreview = false } = Astro.props;
const { Content } = await render(project);
const { title, startDate, endDate, link, repo, tags, experiences } = project.data;
const range = `${startDate.getFullYear()} ${endDate ? endDate.getFullYear() : "Present"}`;
const expEntries = experiences.length ? await getEntries(experiences) : [];
---
<article
@@ -30,7 +30,13 @@ const range = `${startDate.getFullYear()} ${endDate ? endDate.getFullYear()
)
}
</Tag>
<p class="text-muted text-sm">{range}</p>
<p class="text-muted text-sm">
<span>{range}</span>{expEntries.length > 0 && (
<span> ({expEntries.map((exp, i) => (
<Fragment>{i > 0 && ", "}<a class="cactus-link" href={`/experience/${exp.id}/`}>{exp.data.title}</a></Fragment>
))})</span>
)}
</p>
{
!isPreview && (link || repo) && (
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1">
@@ -58,5 +64,4 @@ const range = `${startDate.getFullYear()} ${endDate ? endDate.getFullYear()
<TagList tags={tags} class="border-global-text/10 mt-4 border-t pt-3" />
)
}
{!isPreview && experiences.length > 0 && <div class="mt-6"><ExperienceTags experiences={experiences} /></div>}
</article>
+11 -2
View File
@@ -1,4 +1,5 @@
---
import Analytics from "@vercel/analytics/astro";
import BaseHead from "@/components/BaseHead.astro";
import Footer from "@/components/layout/Footer.astro";
import Header from "@/components/layout/Header.astro";
@@ -12,13 +13,20 @@ interface Props {
}
const {
meta: { articleDate, description = siteConfig.description, ogImage, title },
meta: { articleDate, description = siteConfig.description, keywords, ogImage, schema, title },
} = Astro.props;
---
<html class="scroll-smooth" lang={siteConfig.lang}>
<head>
<BaseHead articleDate={articleDate} description={description} ogImage={ogImage} title={title} />
<BaseHead
articleDate={articleDate}
description={description}
keywords={keywords}
ogImage={ogImage}
schema={schema}
title={title}
/>
<ThemeProvider />
</head>
<body
@@ -30,6 +38,7 @@ const {
<slot />
</main>
<Footer />
<Analytics />
<script is:inline type="speculationrules">
{
"prefetch": [
+22 -1
View File
@@ -4,6 +4,7 @@ import { type CollectionEntry, render } from "astro:content";
import Masthead from "@/components/blog/Masthead.astro";
import TOC from "@/components/blog/TOC.astro";
import WebMentions from "@/components/blog/webmentions/index.astro";
import { articleSchema, breadcrumbList } from "@/utils/seo";
import BaseLayout from "./Base.astro";
@@ -12,18 +13,38 @@ interface Props {
}
const { post } = Astro.props;
const { ogImage, title, description, updatedDate, publishDate } = post.data;
const { ogImage, title, description, updatedDate, publishDate, tags } = post.data;
const socialImage = ogImage ?? `/og-image/writing/${post.id}.png`;
const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString();
const { headings, remarkPluginFrontmatter } = await render(post);
const readingTime: string = remarkPluginFrontmatter.readingTime;
const path = `/writing/${post.id}/`;
const schema = [
breadcrumbList([
{ name: "Home", path: "/" },
{ name: "Writing", path: "/writing/" },
{ name: title, path },
]),
articleSchema({
title,
description,
path,
image: socialImage,
datePublished: publishDate,
dateModified: updatedDate,
keywords: tags,
}),
];
---
<BaseLayout
meta={{
articleDate,
description,
keywords: tags,
ogImage: socialImage,
schema,
title,
}}
>