mirror of
https://github.com/prdlk/website.git
synced 2026-08-03 01:41:53 +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 { 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 type { SiteMeta } from "@/types";
|
||||||
import "@/styles/global.css";
|
import "@/styles/global.css";
|
||||||
|
|
||||||
type Props = SiteMeta;
|
type Props = SiteMeta;
|
||||||
|
|
||||||
const { articleDate, description, ogImage, title } = Astro.props;
|
const { articleDate, description, keywords, ogImage, schema, title } = Astro.props;
|
||||||
|
|
||||||
const titleSeparator = "•";
|
const titleSeparator = "•";
|
||||||
const siteTitle = `${title} ${titleSeparator} ${siteConfig.title}`;
|
const siteTitle = `${title} ${titleSeparator} ${siteConfig.title}`;
|
||||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||||
const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url).href;
|
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" />
|
<meta charset="utf-8" />
|
||||||
@@ -38,8 +42,15 @@ const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url
|
|||||||
{/* Primary Meta Tags */}
|
{/* Primary Meta Tags */}
|
||||||
<meta content={siteTitle} name="title" />
|
<meta content={siteTitle} name="title" />
|
||||||
<meta content={description} name="description" />
|
<meta content={description} name="description" />
|
||||||
|
{keywords && keywords.length > 0 && <meta content={keywords.join(", ")} name="keywords" />}
|
||||||
<meta content={siteConfig.author} name="author" />
|
<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 */}
|
{/* Open Graph / Facebook */}
|
||||||
<meta content={articleDate ? "article" : "website"} property="og:type" />
|
<meta content={articleDate ? "article" : "website"} property="og:type" />
|
||||||
<meta content={title} property="og:title" />
|
<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.title} property="og:site_name" />
|
||||||
<meta content={siteConfig.ogLocale} property="og:locale" />
|
<meta content={siteConfig.ogLocale} property="og:locale" />
|
||||||
<meta content={socialImageURL} property="og:image" />
|
<meta content={socialImageURL} property="og:image" />
|
||||||
|
<meta content={`${title} — ${siteConfig.title}`} property="og:image:alt" />
|
||||||
<meta content="1200" property="og:image:width" />
|
<meta content="1200" property="og:image:width" />
|
||||||
<meta content="630" property="og:image:height" />
|
<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={title} property="twitter:title" />
|
||||||
<meta content={description} property="twitter:description" />
|
<meta content={description} property="twitter:description" />
|
||||||
<meta content={socialImageURL} property="twitter:image" />
|
<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 */}
|
{/* Sitemap */}
|
||||||
<link href="/sitemap-index.xml" rel="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>
|
<li>
|
||||||
<a
|
<a
|
||||||
class="text-muted hover:text-link text-xs before:content-['#']"
|
class="text-muted hover:text-link text-xs before:content-['#']"
|
||||||
href={`/tags/${slugifyTag(tag)}/`}
|
href={`/skills/${slugifyTag(tag)}/`}
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</a>
|
</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 type { CollectionEntry } from "astro:content";
|
||||||
import ExperienceTags from "@/components/ExperienceTags.astro";
|
import ExperienceTags from "@/components/ExperienceTags.astro";
|
||||||
import FormattedDate from "@/components/FormattedDate.astro";
|
import FormattedDate from "@/components/FormattedDate.astro";
|
||||||
|
import TagList from "@/components/TagList.astro";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: CollectionEntry<"writing">;
|
content: CollectionEntry<"writing">;
|
||||||
@@ -58,3 +59,4 @@ const dateTimeOptions: Intl.DateTimeFormatOptions = {
|
|||||||
</div>
|
</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 { 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} />
|
<FormattedDate class="text-muted min-w-30 font-semibold" date={post.data.publishDate} />
|
||||||
<Tag>
|
<Tag>
|
||||||
{post.data.draft && <span class="text-red-500">(Draft) </span>}
|
{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}
|
{post.data.title}
|
||||||
</a>
|
</a>
|
||||||
|
{
|
||||||
|
externalUrl && (
|
||||||
|
<span class="text-muted text-xs whitespace-nowrap">
|
||||||
|
{" ↗ "}
|
||||||
|
{publisher ?? "External"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
</Tag>
|
</Tag>
|
||||||
{withDesc && <q class="line-clamp-3 italic">{post.data.description}</q>}
|
{withDesc && <q class="line-clamp-3 italic">{post.data.description}</q>}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const year = new Date().getFullYear();
|
|||||||
{/* Row 1: site nav */}
|
{/* Row 1: site nav */}
|
||||||
<nav aria-labelledby="footer_links" class="flex flex-wrap justify-center gap-x-1 gap-y-1">
|
<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>
|
<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) => (
|
menuLinks.map((link) => (
|
||||||
<a class="hover:text-global-text px-3 py-1 hover:underline" href={link.path}>
|
<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
|
<a
|
||||||
class="hover:text-link inline-block"
|
class="hover:text-link inline-block"
|
||||||
href={link}
|
href={link}
|
||||||
rel={`noreferrer ${isWebmention ? "me authn" : ""}`}
|
rel={`me noreferrer ${isWebmention ? "authn" : ""}`}
|
||||||
target={link.startsWith("mailto:") ? undefined : "_blank"}
|
target={link.startsWith("mailto:") ? undefined : "_blank"}
|
||||||
>
|
>
|
||||||
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name={name} />
|
<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 type { HTMLTag, Polymorphic } from "astro/types";
|
||||||
import ExperienceTags from "@/components/ExperienceTags.astro";
|
|
||||||
import TagList from "@/components/TagList.astro";
|
import TagList from "@/components/TagList.astro";
|
||||||
|
|
||||||
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
|
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 { Content } = await render(project);
|
||||||
const { title, startDate, endDate, link, repo, tags, experiences } = project.data;
|
const { title, startDate, endDate, link, repo, tags, experiences } = project.data;
|
||||||
const range = `${startDate.getFullYear()} – ${endDate ? endDate.getFullYear() : "Present"}`;
|
const range = `${startDate.getFullYear()} – ${endDate ? endDate.getFullYear() : "Present"}`;
|
||||||
|
const expEntries = experiences.length ? await getEntries(experiences) : [];
|
||||||
---
|
---
|
||||||
|
|
||||||
<article
|
<article
|
||||||
@@ -30,7 +30,13 @@ const range = `${startDate.getFullYear()} – ${endDate ? endDate.getFullYear()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</Tag>
|
</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) && (
|
!isPreview && (link || repo) && (
|
||||||
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1">
|
<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" />
|
<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>
|
</article>
|
||||||
|
|||||||
+11
-2
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
import Analytics from "@vercel/analytics/astro";
|
||||||
import BaseHead from "@/components/BaseHead.astro";
|
import BaseHead from "@/components/BaseHead.astro";
|
||||||
import Footer from "@/components/layout/Footer.astro";
|
import Footer from "@/components/layout/Footer.astro";
|
||||||
import Header from "@/components/layout/Header.astro";
|
import Header from "@/components/layout/Header.astro";
|
||||||
@@ -12,13 +13,20 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
meta: { articleDate, description = siteConfig.description, ogImage, title },
|
meta: { articleDate, description = siteConfig.description, keywords, ogImage, schema, title },
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<html class="scroll-smooth" lang={siteConfig.lang}>
|
<html class="scroll-smooth" lang={siteConfig.lang}>
|
||||||
<head>
|
<head>
|
||||||
<BaseHead articleDate={articleDate} description={description} ogImage={ogImage} title={title} />
|
<BaseHead
|
||||||
|
articleDate={articleDate}
|
||||||
|
description={description}
|
||||||
|
keywords={keywords}
|
||||||
|
ogImage={ogImage}
|
||||||
|
schema={schema}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
<ThemeProvider />
|
<ThemeProvider />
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
@@ -30,6 +38,7 @@ const {
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<Analytics />
|
||||||
<script is:inline type="speculationrules">
|
<script is:inline type="speculationrules">
|
||||||
{
|
{
|
||||||
"prefetch": [
|
"prefetch": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type CollectionEntry, render } from "astro:content";
|
|||||||
import Masthead from "@/components/blog/Masthead.astro";
|
import Masthead from "@/components/blog/Masthead.astro";
|
||||||
import TOC from "@/components/blog/TOC.astro";
|
import TOC from "@/components/blog/TOC.astro";
|
||||||
import WebMentions from "@/components/blog/webmentions/index.astro";
|
import WebMentions from "@/components/blog/webmentions/index.astro";
|
||||||
|
import { articleSchema, breadcrumbList } from "@/utils/seo";
|
||||||
|
|
||||||
import BaseLayout from "./Base.astro";
|
import BaseLayout from "./Base.astro";
|
||||||
|
|
||||||
@@ -12,18 +13,38 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { post } = Astro.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 socialImage = ogImage ?? `/og-image/writing/${post.id}.png`;
|
||||||
const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString();
|
const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString();
|
||||||
const { headings, remarkPluginFrontmatter } = await render(post);
|
const { headings, remarkPluginFrontmatter } = await render(post);
|
||||||
const readingTime: string = remarkPluginFrontmatter.readingTime;
|
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
|
<BaseLayout
|
||||||
meta={{
|
meta={{
|
||||||
articleDate,
|
articleDate,
|
||||||
description,
|
description,
|
||||||
|
keywords: tags,
|
||||||
ogImage: socialImage,
|
ogImage: socialImage,
|
||||||
|
schema,
|
||||||
title,
|
title,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user