mirror of
https://github.com/prdlk/website.git
synced 2026-08-02 09:21:41 +00:00
refactor(ui): enhance SEO metadata and structured data injection
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user