From f8e1b79e7379be49e88b06f5372ae69723affe63 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Tue, 30 Jun 2026 14:28:52 -0400 Subject: [PATCH] feat(skill): add skill pages and SEO schema --- src/icons/x.svg | 3 + src/pages/skills/[skill].astro | 117 +++++++++++++++++++++++++++ src/pages/skills/index.astro | 45 +++++++++++ src/utils/seo.ts | 144 +++++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+) create mode 100644 src/icons/x.svg create mode 100644 src/pages/skills/[skill].astro create mode 100644 src/pages/skills/index.astro create mode 100644 src/utils/seo.ts diff --git a/src/icons/x.svg b/src/icons/x.svg new file mode 100644 index 0000000..4fad2be --- /dev/null +++ b/src/icons/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/pages/skills/[skill].astro b/src/pages/skills/[skill].astro new file mode 100644 index 0000000..c39fde6 --- /dev/null +++ b/src/pages/skills/[skill].astro @@ -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; + +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, + })), + }, +]; +--- + + + +

{label}

+

{total} item{total === 1 ? "" : "s"}

+ + { + simpleList.map( + (group) => + group.items.length > 0 && ( +
+

{group.heading}

+
    + {group.items.map((item) => ( +
  • + + {item.title} + + {item.note && · {item.note}} +
  • + ))} +
+
+ ), + ) + } + + { + writing.length > 0 && ( +
+

Writing

+
    + {writing.map((p) => ( +
  • + +
  • + ))} +
+
+ ) + } +
diff --git a/src/pages/skills/index.astro b/src/pages/skills/index.astro new file mode 100644 index 0000000..22b0172 --- /dev/null +++ b/src/pages/skills/index.astro @@ -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", +}; +--- + + +

Skills

+

Skills and topics spanning my projects, experience, writing, and talks.

+
    + { + tags.map(({ slug, label, count }) => ( +
  • + + {label} + + {count} +
  • + )) + } +
+
diff --git a/src/utils/seo.ts b/src/utils/seo.ts new file mode 100644 index 0000000..e850cd8 --- /dev/null +++ b/src/utils/seo.ts @@ -0,0 +1,144 @@ +import { siteConfig, socialLinks } from "@/site.config"; + +type Schema = Record; + +/** 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(", ") } : {}), + }; +}