mirror of
https://github.com/prdlk/website.git
synced 2026-08-02 17:31:41 +00:00
feat(skill): add skill pages and SEO schema
This commit is contained in:
@@ -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 |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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(", ") } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user