feat(skill): add skill pages and SEO schema

This commit is contained in:
Prad Nukala
2026-06-30 14:28:52 -04:00
parent 0e45270d93
commit f8e1b79e73
4 changed files with 309 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

+117
View File
@@ -0,0 +1,117 @@
---
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
import { Icon } from "astro-icon/components";
import PostPreview from "@/components/blog/PostPreview.astro";
import { filterByTag, getTagIndex, getTaggedCollections } from "@/data/tags";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort, projectDateSort } from "@/utils/date";
import { breadcrumbList } from "@/utils/seo";
export const getStaticPaths = (async () => {
const cols = await getTaggedCollections();
const index = getTagIndex(cols);
return [...index.entries()].map(([slug, { label }]) => {
const groups = filterByTag(cols, label);
return {
params: { skill: slug },
props: {
label,
projects: groups.projects.sort(projectDateSort),
experience: groups.experience.sort(
(a, b) => b.data.startDate.valueOf() - a.data.startDate.valueOf(),
),
writing: groups.writing.sort(collectionDateSort),
speaking: groups.speaking.sort(collectionDateSort),
},
};
});
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { label, projects, experience, writing, speaking } = Astro.props as Props;
const total = projects.length + experience.length + writing.length + speaking.length;
const meta = {
description: `Projects, experience, writing, and talks involving ${label}.`,
keywords: [label],
schema: breadcrumbList([
{ name: "Home", path: "/" },
{ name: "Skills", path: "/skills/" },
{ name: label, path: `/skills/${Astro.params.skill}/` },
]),
title: `${label} · Skills`,
};
const simpleList: { heading: string; items: { href: string; title: string; note?: string }[] }[] = [
{
heading: "Projects",
items: projects.map((p) => ({ href: `/projects/${p.id}/`, title: p.data.title })),
},
{
heading: "Experience",
items: experience.map((e) => ({
href: `/experience/${e.id}/`,
title: e.data.role ? `${e.data.role}, ${e.data.organization}` : e.data.organization,
})),
},
{
heading: "Speaking",
items: speaking.map((t) => ({
href: `/speaking/${t.id}/`,
title: t.data.title,
note: t.data.event,
})),
},
];
---
<PageLayout meta={meta}>
<nav class="mb-8" aria-label="Breadcrumbs">
<ul class="flex items-center">
<li class="flex items-center">
<a class="text-accent" href="/skills/">Skills</a>
<Icon aria-hidden="true" name="mdi:chevron-right" class="mx-1.5" />
</li>
<li aria-current="page"><span aria-hidden="true">#</span>{label}</li>
</ul>
</nav>
<h1 class="title mb-2"><span aria-hidden="true">#</span>{label}</h1>
<p class="text-muted mb-12">{total} item{total === 1 ? "" : "s"}</p>
{
simpleList.map(
(group) =>
group.items.length > 0 && (
<section class="mb-12">
<h2 class="title mb-4 text-xl">{group.heading}</h2>
<ul class="space-y-3">
{group.items.map((item) => (
<li>
<a class="cactus-link" href={item.href}>
{item.title}
</a>
{item.note && <span class="text-muted text-sm"> · {item.note}</span>}
</li>
))}
</ul>
</section>
),
)
}
{
writing.length > 0 && (
<section class="mb-12">
<h2 class="title mb-4 text-xl">Writing</h2>
<ul class="space-y-4">
{writing.map((p) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr]">
<PostPreview as="h3" post={p} />
</li>
))}
</ul>
</section>
)
}
</PageLayout>
+45
View File
@@ -0,0 +1,45 @@
---
import { getTagIndex, getTaggedCollections } from "@/data/tags";
import PageLayout from "@/layouts/Base.astro";
import { breadcrumbList, collectionPageSchema } from "@/utils/seo";
const cols = await getTaggedCollections();
const tags = [...getTagIndex(cols).entries()]
.map(([slug, { label, count }]) => ({ slug, label, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
const meta = {
description: "Browse projects, experience, writing, and talks by skill and topic.",
keywords: tags.map((t) => t.label),
schema: [
breadcrumbList([
{ name: "Home", path: "/" },
{ name: "Skills", path: "/skills/" },
]),
collectionPageSchema({
name: "Skills",
description: "Browse projects, experience, writing, and talks by skill and topic.",
path: "/skills/",
items: tags.map((t) => ({ name: t.label, path: `/skills/${t.slug}/` })),
}),
],
title: "Skills",
};
---
<PageLayout meta={meta}>
<h1 class="title mb-6">Skills</h1>
<p class="text-muted mb-8">Skills and topics spanning my projects, experience, writing, and talks.</p>
<ul class="flex flex-wrap gap-x-4 gap-y-2">
{
tags.map(({ slug, label, count }) => (
<li>
<a class="cactus-link before:content-['#']" href={`/skills/${slug}/`}>
{label}
</a>
<span class="text-muted text-sm"> {count}</span>
</li>
))
}
</ul>
</PageLayout>
+144
View File
@@ -0,0 +1,144 @@
import { siteConfig, socialLinks } from "@/site.config";
type Schema = Record<string, unknown>;
/** Absolute URL from a site-root path. */
export const abs = (path: string) => new URL(path, siteConfig.url).href;
const authorRef = () => ({
"@type": "Person",
name: siteConfig.author,
url: siteConfig.url,
});
/** schema.org BreadcrumbList — Google renders these as the breadcrumb trail in results. */
export function breadcrumbList(items: { name: string; path: string }[]): Schema {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((it, i) => ({
"@type": "ListItem",
position: i + 1,
name: it.name,
item: abs(it.path),
})),
};
}
/** Person — connects social profiles (sameAs) and skills (knowsAbout) to the author. */
export function personSchema(knowsAbout: string[] = []): Schema {
return {
"@context": "https://schema.org",
"@type": "Person",
name: siteConfig.author,
url: siteConfig.url,
image: abs("/avatar.png"),
jobTitle: "Engineer & Founder",
description: siteConfig.description,
sameAs: socialLinks.filter((s) => !s.link.startsWith("mailto:")).map((s) => s.link),
...(knowsAbout.length ? { knowsAbout } : {}),
};
}
export function websiteSchema(): Schema {
return {
"@context": "https://schema.org",
"@type": "WebSite",
name: siteConfig.title,
url: siteConfig.url,
description: siteConfig.description,
author: authorRef(),
};
}
export function articleSchema(opts: {
title: string;
description?: string;
path: string;
image: string;
datePublished: Date;
dateModified?: Date;
keywords?: string[];
}): Schema {
return {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: opts.title,
description: opts.description,
image: abs(opts.image),
datePublished: opts.datePublished.toISOString(),
dateModified: (opts.dateModified ?? opts.datePublished).toISOString(),
author: authorRef(),
publisher: authorRef(),
mainEntityOfPage: { "@type": "WebPage", "@id": abs(opts.path) },
...(opts.keywords?.length ? { keywords: opts.keywords.join(", ") } : {}),
};
}
export function videoSchema(opts: {
title: string;
description: string;
path: string;
thumbnail: string;
uploadDate: Date;
embedUrl?: string;
contentUrl?: string;
}): Schema {
return {
"@context": "https://schema.org",
"@type": "VideoObject",
name: opts.title,
description: opts.description,
thumbnailUrl: abs(opts.thumbnail),
uploadDate: opts.uploadDate.toISOString(),
...(opts.embedUrl ? { embedUrl: opts.embedUrl } : {}),
...(opts.contentUrl ? { contentUrl: opts.contentUrl } : {}),
};
}
/** CollectionPage wrapping an ItemList — for section/list pages. */
export function collectionPageSchema(opts: {
name: string;
description?: string;
path: string;
items: { name: string; path: string }[];
}): Schema {
return {
"@context": "https://schema.org",
"@type": "CollectionPage",
name: opts.name,
...(opts.description ? { description: opts.description } : {}),
url: abs(opts.path),
mainEntity: {
"@type": "ItemList",
itemListElement: opts.items.map((it, i) => ({
"@type": "ListItem",
position: i + 1,
name: it.name,
url: abs(it.path),
})),
},
};
}
export function creativeWorkSchema(opts: {
title: string;
description?: string;
path: string;
url?: string;
repo?: string;
dateCreated: Date;
keywords?: string[];
}): Schema {
return {
"@context": "https://schema.org",
"@type": "CreativeWork",
name: opts.title,
description: opts.description,
url: opts.url ?? abs(opts.path),
dateCreated: opts.dateCreated.toISOString(),
author: authorRef(),
...(opts.repo ? { codeRepository: opts.repo } : {}),
...(opts.keywords?.length ? { keywords: opts.keywords.join(", ") } : {}),
};
}