feat(pages): add new site pages, routes, and OG image generation

This commit is contained in:
Prad Nukala committed 2026-08-19 14:11:51 -04:00
1 parent 8a191f65cc
commit 01eac43ecf
19 files changed
+1206

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
---
import PageLayout from "@/layouts/Base.astro";
const meta = {
description: "Oops! It looks like this page is lost in space!",
title: "Oops! You found a missing page!",
};
---
<PageLayout meta={meta}>
<h1 class="title mb-12">404 | Oops something went wrong</h1>
<p class="mb-8">Please use the navigation to find your way back</p>
</PageLayout>
+121
View File
@@ -0,0 +1,121 @@
---
import { render } from "astro:content";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
import { Icon } from "astro-icon/components";
import PostPreview from "@/components/blog/PostPreview.astro";
import TagList from "@/components/TagList.astro";
import { getAllExperience, getContentForExperience } from "@/data/experience";
import PageLayout from "@/layouts/Base.astro";
import { breadcrumbList } from "@/utils/seo";
export const getStaticPaths = (async () => {
const experiences = await getAllExperience();
return Promise.all(
experiences.map(async (exp) => ({
params: { slug: exp.id },
props: { exp, content: await getContentForExperience(exp.id) },
})),
);
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { exp, content } = Astro.props as Props;
const { Content } = await render(exp);
const { startDate, endDate, organization, role, location, url, tags } = exp.data;
const range = `${startDate.getFullYear()} – ${endDate ? endDate.getFullYear() : "Present"}`;
const meta = {
description: exp.data.description ?? `${role ?? ""} at ${organization}`.trim(),
keywords: tags,
ogImage: `/og-image/experience/${exp.id}.png`,
schema: breadcrumbList([
{ name: "Home", path: "/" },
{ name: "Experience", path: "/experience/" },
{ name: exp.data.title, path: `/experience/${exp.id}/` },
]),
title: exp.data.title,
};
---
<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="/experience/">Experience</a>
<Icon aria-hidden="true" name="lucide:chevron-right" class="mx-1.5" />
</li>
<li aria-current="page">{exp.data.title}</li>
</ul>
</nav>
<h1 class="title">{role ? `${role}, ${organization}` : organization}</h1>
<p class="text-muted mt-1">
{range}{location && ` · ${location}`}
{
url && (
<>
{" · "}
<a class="cactus-link" href={url} rel="noopener noreferrer" target="_blank">
Website →
</a>
</>
)
}
</p>
<div class="prose prose-sm prose-cactus mt-6 mb-12 max-w-none">
<Content />
</div>
{
content.projects.length > 0 && (
<section class="mb-12">
<h2 class="title mb-4 text-xl">Projects</h2>
<ul class="space-y-3">
{content.projects.map((p) => (
<li>
<a class="cactus-link" href={`/projects/${p.id}/`}>
{p.data.title}
</a>
</li>
))}
</ul>
</section>
)
}
{
content.writing.length > 0 && (
<section class="mb-12">
<h2 class="title mb-4 text-xl">Writing</h2>
<ul class="space-y-4">
{content.writing.map((p) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr]">
<PostPreview as="h3" post={p} />
</li>
))}
</ul>
</section>
)
}
{
content.speaking.length > 0 && (
<section class="mb-12">
<h2 class="title mb-4 text-xl">Speaking</h2>
<ul class="space-y-3">
{content.speaking.map((t) => (
<li>
<a class="cactus-link" href={`/speaking/${t.id}/`}>
{t.data.title}
</a>
</li>
))}
</ul>
</section>
)
}
<TagList tags={tags} class="border-global-text/10 mt-6 border-t pt-6" />
</PageLayout>
+64
View File
@@ -0,0 +1,64 @@
---
import { getAllExperience } from "@/data/experience";
import PageLayout from "@/layouts/Base.astro";
import { breadcrumbList, collectionPageSchema } from "@/utils/seo";
const experiences = await getAllExperience();
const meta = {
description: "Roles and organizations I've worked with",
schema: [
breadcrumbList([
{ name: "Home", path: "/" },
{ name: "Experience", path: "/experience/" },
]),
collectionPageSchema({
name: "Experience",
description: "Roles and organizations I've worked with",
path: "/experience/",
items: experiences.map((e) => ({
name: e.data.role ? `${e.data.role}, ${e.data.organization}` : e.data.organization,
path: `/experience/${e.id}/`,
})),
}),
],
title: "Experience",
};
const range = (start: Date, end?: Date) =>
`${start.getFullYear()} – ${end ? end.getFullYear() : "Present"}`;
---
<PageLayout meta={meta}>
<h1 class="title mb-12">Experience</h1>
<ul class="space-y-10">
{
experiences.map((exp) => (
<li>
<div class="flex flex-wrap items-baseline justify-between gap-x-4">
<h2 class="title text-lg">
<a class="cactus-link" href={`/experience/${exp.id}/`}>
{exp.data.role ? `${exp.data.role}, ${exp.data.organization}` : exp.data.organization}
</a>
</h2>
<span class="text-muted text-sm">{range(exp.data.startDate, exp.data.endDate)}</span>
</div>
{exp.data.location && <p class="text-muted text-sm">{exp.data.location}</p>}
{exp.data.description && <p class="mt-2">{exp.data.description}</p>}
</li>
))
}
</ul>
<section class="mt-16">
<h2 class="title text-accent mb-6 text-xl">Education</h2>
<p>
<strong>Virginia Commonwealth University</strong><br />
B.S. in Applied Mathematics
</p>
<p class="mt-2">
Attended 5 MLH hackathons, published 3 apps in 3 years with over 1M downloads, and wrote 3
articles with over 120k reads. Coursework spanned linear algebra, number theory, topological
data analysis, and vector calculus.
</p>
</section>
</PageLayout>
+142
View File
@@ -0,0 +1,142 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import PostPreview from "@/components/blog/PostPreview.astro";
import Project from "@/components/project/Project.astro";
import { getAllPosts } from "@/data/post";
import { getAllSpeaking } from "@/data/speaking";
import PageLayout from "@/layouts/Base.astro";
import { socialLinks } from "@/site.config";
import { getTagIndex, getTaggedCollections } from "@/data/tags";
import { collectionDateSort, projectDateSort } from "@/utils/date";
import { personSchema, websiteSchema } from "@/utils/seo";
const MAX_POSTS = 4;
const allPosts = await getAllPosts();
const latestPosts = (allPosts.sort(collectionDateSort) as CollectionEntry<"writing">[]).slice(
0,
MAX_POSTS,
);
const MAX_PROJECTS = 3;
const allProjects = await getCollection("projects");
const latestProjects = allProjects.sort(projectDateSort).slice(0, MAX_PROJECTS);
const talks = (await getAllSpeaking()).sort(collectionDateSort);
const social = Object.fromEntries(socialLinks.map((s) => [s.friendlyName, s.link]));
// Skills (from all tagged content) feed the Person `knowsAbout` signal.
const skills = [...getTagIndex(await getTaggedCollections()).values()].map((t) => t.label);
const meta = {
title: "Engineer & Founder",
description:
"Prad Nukala — engineer and founder building decentralized identity infrastructure. Explore my projects, experience, writing, and talks.",
schema: [websiteSchema(), personSchema(skills)],
};
---
<PageLayout meta={meta}>
<section>
<h1 class="title mb-6">About</h1>
<p class="mb-4">
I'm Prad Nukala — an engineer and founder building decentralized identity infrastructure. As
co-founder and CEO of <a class="cactus-link" href="https://sonr.io" rel="noreferrer" target="_blank">Sonr</a>, I'm
building a peer-to-peer identity and data network powered by decentralized identifiers (DIDs),
WebAuthn, and IPFS.
</p>
<p class="mb-4">
I help shape the open standards behind user-owned identity as a working-group member at the
W3C (<a class="cactus-link" href="https://www.w3.org/groups/wg/did/former-participants/" rel="noreferrer" target="_blank">DIDs</a>,
WebAuthn, <a class="cactus-link" href="https://www.w3.org/groups/wg/wasm/former-participants/" rel="noreferrer" target="_blank">WebAssembly</a>)
and the Decentralized Identity Foundation (UCAN, DWN). I've been shipping software since age
12 — three apps with over 1M downloads, technical writing read 120k+ times, and bylines in
Fast Company.
</p>
<p>
Find me on <a class="cactus-link" href={social.GitHub} rel="me noreferrer" target="_blank">Github</a>,
<a class="cactus-link" href={social.X} rel="me noreferrer" target="_blank">X</a>,
<a class="cactus-link" href={social.LinkedIn} rel="me noreferrer" target="_blank">LinkedIn</a>, or
<a class="cactus-link" href={social.Email}>contact me directly</a>.
</p>
</section>
{
latestProjects.length > 0 && (
<section class="mt-16">
<h2 class="title text-accent mb-6 text-xl">
<a href="/projects/">Projects</a>
</h2>
<ul class="space-y-6" role="list">
{latestProjects.map((project) => (
<li>
<Project project={project} as="h3" isPreview />
</li>
))}
</ul>
<a class="hover:text-link mt-6 inline-block" href="/projects/">
See all projects <span aria-hidden="true">→</span>
</a>
</section>
)
}
<section class="mt-16">
<h2 class="title text-accent mb-6 text-xl"><a href="/writing/">Writing</a></h2>
<ul class="space-y-4" role="list">
{
latestPosts.map((p) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr]">
<PostPreview post={p} />
</li>
))
}
</ul>
<a class="hover:text-link mt-6 inline-block" href="/writing/">
See all writing <span aria-hidden="true">→</span>
</a>
</section>
{
talks.length > 0 && (
<section class="mt-16">
<h2 class="title text-accent mb-6 text-xl">
<a href="/speaking/">Speaking</a>
</h2>
<ul class="space-y-4" role="list">
{talks.map((talk) => (
<li>
<a class="cactus-link" href={`/speaking/${talk.id}/`}>
{talk.data.title}
</a>
{talk.data.event && <span class="text-muted text-sm"> · {talk.data.event}</span>}
</li>
))}
</ul>
</section>
)
}
<section class="mt-16">
<h2 class="title text-accent mb-6 text-xl">Honors &amp; Recognition</h2>
<ul class="space-y-4" role="list">
<li>Guest Lecturer, <em>Crypto Finance (15.492)</em> — MIT Sloan School of Management</li>
</ul>
</section>
<section class="mt-16">
<h2 class="title text-accent mb-6 text-xl">Standards &amp; Organizations</h2>
<ul class="space-y-4" role="list">
<li>
<a class="cactus-link" href="https://www.w3.org/" rel="noreferrer" target="_blank">
World Wide Web Consortium (W3C)
</a>
— Working Group —
<a class="cactus-link" href="https://www.w3.org/groups/wg/did/former-participants/" rel="noreferrer" target="_blank">DIDs</a>,
WebAuthn,
<a class="cactus-link" href="https://www.w3.org/groups/wg/wasm/former-participants/" rel="noreferrer" target="_blank">WASM</a>
</li>
<li>
<a class="cactus-link" href="https://identity.foundation/" rel="noreferrer" target="_blank">
Decentralized Identity Foundation (DIF)
</a>
— Working Group — UCAN, DWN
</li>
</ul>
</section>
</PageLayout>
+7
View File
@@ -0,0 +1,7 @@
import type { APIRoute } from "astro";
import { buildLlmsFull } from "@/data/llms";
export const GET: APIRoute = async () =>
new Response(await buildLlmsFull(), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
+7
View File
@@ -0,0 +1,7 @@
import type { APIRoute } from "astro";
import { buildLlmsIndex } from "@/data/llms";
export const GET: APIRoute = async () =>
new Response(await buildLlmsIndex(), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
+111
View File
@@ -0,0 +1,111 @@
import { getCollection } from "astro:content";
import type { APIContext } from "astro";
import satori, { type SatoriOptions } from "satori";
import sharp from "sharp";
import RobotoMonoBold from "@/assets/roboto-mono-700.ttf";
import RobotoMono from "@/assets/roboto-mono-regular.ttf";
import { getAllPosts } from "@/data/post";
import { getAllSpeaking } from "@/data/speaking";
import { getFormattedDate } from "@/utils/date";
import { readCache, writeToCache } from "./_cacheUtil";
import { type OgContent, ogMarkup } from "./_ogMarkup";
const ogOptions: SatoriOptions = {
// debug: true,
fonts: [
{
data: Buffer.from(RobotoMono),
name: "Roboto Mono",
style: "normal",
weight: 400,
},
{
data: Buffer.from(RobotoMonoBold),
name: "Roboto Mono",
style: "normal",
weight: 700,
},
],
height: 630,
width: 1200,
};
type Props = OgContent & { cacheDate: Date };
export async function GET(context: APIContext) {
const { cacheDate, ...content } = context.props as Props;
let pngBuffer = readCache(content.title, cacheDate);
if (!pngBuffer) {
console.info(`Generating new OG image for: ${content.title}`);
const svg = await satori(ogMarkup(content), ogOptions);
pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
writeToCache(content.title, cacheDate, pngBuffer);
}
return new Response(new Uint8Array(pngBuffer), {
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": "image/png",
},
});
}
export async function getStaticPaths() {
const date = (d: Date) => getFormattedDate(d, { month: "long" });
const [projects, experiences, speaking] = await Promise.all([
getCollection("projects"),
getCollection("experience"),
getAllSpeaking(),
]);
// Writing: skip posts with a custom ogImage, and external (link-out) articles.
const writing = (await getAllPosts())
.filter((p) => !p.data.ogImage && !p.data.externalUrl)
.map((p) => ({
params: { slug: `writing/${p.id}` },
props: {
category: "Writing",
title: p.data.title,
excerpt: p.data.description,
meta: date(p.data.updatedDate ?? p.data.publishDate),
cacheDate: p.data.updatedDate ?? p.data.publishDate,
},
}));
const projectPaths = projects.map((p) => ({
params: { slug: `projects/${p.id}` },
props: {
category: "Project",
title: p.data.title,
excerpt: p.data.description,
meta: `${p.data.startDate.getFullYear()} – ${p.data.endDate ? p.data.endDate.getFullYear() : "Present"}`,
cacheDate: p.data.endDate ?? p.data.startDate,
},
}));
const speakingPaths = speaking.map((t) => ({
params: { slug: `speaking/${t.id}` },
props: {
category: "Talk",
title: t.data.title,
excerpt: t.data.description,
meta: t.data.event ?? date(t.data.publishDate),
cacheDate: t.data.publishDate,
},
}));
const experiencePaths = experiences.map((e) => ({
params: { slug: `experience/${e.id}` },
props: {
category: "Experience",
title: e.data.role ? `${e.data.role}, ${e.data.organization}` : e.data.organization,
excerpt: e.data.description,
meta: e.data.location ?? e.data.organization,
cacheDate: e.data.startDate,
},
}));
return [...writing, ...projectPaths, ...speakingPaths, ...experiencePaths];
}
+45
View File
@@ -0,0 +1,45 @@
import { cacheDir } from "astro:config/server";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Update/bump version when changing markup in _ogMarkup.ts, or fonts/config in [...slug].png.ts
const CACHE_VERSION = "v5";
// Cache directory, defaults to node_modules/.astro/og-images
const CACHE_DIR = path.join(fileURLToPath(cacheDir), "og-images");
// Check cache directory exists, if not create dir
function checkCacheDirExists() {
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
}
// Returns a cache key based on CACHE_VERSION, a post's title & pubDate
function getCacheKey(title: string, pubDate: Date): string {
const content = `${CACHE_VERSION}-${title}-${pubDate.toISOString()}`;
return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
}
// Return an OG Image from cache if it exists
export function readCache(title: string, pubDate: Date): Buffer | null {
checkCacheDirExists();
const cacheKey = getCacheKey(title, pubDate);
const cacheFilePath = path.join(CACHE_DIR, `${cacheKey}.png`);
if (fs.existsSync(cacheFilePath)) {
console.info(`Found cached OG image for: ${title}`);
return fs.readFileSync(cacheFilePath);
}
return null;
}
// Save image to cache
export function writeToCache(title: string, pubDate: Date, data: Buffer): void {
const cacheKey = getCacheKey(title, pubDate);
fs.writeFileSync(path.join(CACHE_DIR, `${cacheKey}.png`), data);
}
+89
View File
@@ -0,0 +1,89 @@
import { siteConfig } from "@/site.config";
// OG image markup. Structure adapted from ogimagecn's "Blog" component
// (https://www.ogimagecn.com/docs/components/blog) — a type badge, title,
// excerpt, and author/meta line — restyled to match the site's minimal
// social-card.png: dark background, green accent frame, mono type.
//
// Built as a plain Satori node tree (not satori-html) so dynamic text is
// rendered literally — satori-html escapes `&`/`<`/`>` in interpolations and
// would otherwise render e.g. "Founder & Lead Engineer" as "Founder &amp;…".
const BG = "#1d1f21";
const WHITE = "#ffffff";
const MUTED = "#8a8f98";
const ACCENT = "#2bbc89";
type Style = Record<string, string | number>;
type Node = { type: string; props: { style: Style; children?: unknown } };
const el = (type: string, style: Style, children?: unknown): Node => ({
type,
props: { style: { display: "flex", ...style }, ...(children !== undefined ? { children } : {}) },
});
export interface OgContent {
/** Content type shown as the eyebrow label, e.g. "Writing", "Project", "Talk". */
category: string;
title: string;
/** Short description shown under the title. */
excerpt?: string;
/** Secondary line next to the author, e.g. a formatted date or event. */
meta: string;
}
export const ogMarkup = ({ category, title, excerpt, meta }: OgContent): Node =>
el(
"div",
{
flexDirection: "column",
width: "100%",
height: "100%",
justifyContent: "space-between",
backgroundColor: BG,
color: MUTED,
fontFamily: "Roboto Mono",
border: `14px solid ${ACCENT}`,
padding: "72px",
},
[
el("div", { alignItems: "center", justifyContent: "space-between", width: "100%" }, [
el(
"div",
{
color: ACCENT,
fontSize: "26px",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.18em",
},
category,
),
el("div", { color: WHITE, fontSize: "28px", fontWeight: 700 }, siteConfig.title),
]),
el("div", { flexDirection: "column" }, [
el(
"div",
{
color: WHITE,
fontWeight: 700,
fontSize: title.length > 48 ? "62px" : "78px",
lineHeight: 1.1,
letterSpacing: "-0.02em",
maxWidth: "1040px",
},
title,
),
...(excerpt
? [
el(
"div",
{ color: MUTED, fontSize: "32px", lineHeight: 1.4, maxWidth: "980px", marginTop: "32px" },
excerpt,
),
]
: []),
]),
el("div", { color: MUTED, fontSize: "26px" }, `by ${siteConfig.author} · ${meta}`),
],
);
+75
View File
@@ -0,0 +1,75 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import type { GetStaticPaths, Page } from "astro";
import { Icon } from "astro-icon/components";
import Pagination from "@/components/Paginator.astro";
import Project from "@/components/project/Project.astro";
import PageLayout from "@/layouts/Base.astro";
import { projectDateSort } from "@/utils/date";
import { breadcrumbList, collectionPageSchema } from "@/utils/seo";
export const getStaticPaths = (async ({ paginate }) => {
const MAX_PROJECTS_PER_PAGE = 10;
const allProjects = await getCollection("projects");
return paginate(allProjects.sort(projectDateSort), { pageSize: MAX_PROJECTS_PER_PAGE });
}) satisfies GetStaticPaths;
interface Props {
page: Page<CollectionEntry<"projects">>;
}
const { page } = Astro.props;
const meta = {
description: "Things I've built",
schema: [
breadcrumbList([
{ name: "Home", path: "/" },
{ name: "Projects", path: "/projects/" },
]),
collectionPageSchema({
name: "Projects",
description: "Things I've built",
path: page.url.current,
items: page.data.map((p) => ({ name: p.data.title, path: `/projects/${p.id}/` })),
}),
],
title: "Projects",
};
const paginationProps = {
...(page.url.prev && {
prevUrl: {
text: "← Previous Page",
url: page.url.prev,
},
}),
...(page.url.next && {
nextUrl: {
text: "Next Page →",
url: page.url.next,
},
}),
};
---
<PageLayout meta={meta}>
<section>
<h1 class="title mb-12 flex items-center gap-3">
Projects <a class="text-accent" href="/projects/rss.xml" target="_blank">
<span class="sr-only">RSS feed</span>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="lucide:rss" />
</a>
</h1>
<ul class="mt-6 space-y-8 text-start">
{
page.data.map((project) => (
<li class="">
<Project project={project} as="h2" isPreview />
</li>
))
}
</ul>
<Pagination {...paginationProps} />
</section>
</PageLayout>
+41
View File
@@ -0,0 +1,41 @@
---
import { getCollection } from "astro:content";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
import Project from "@/components/project/Project.astro";
import PageLayout from "@/layouts/Base.astro";
import { breadcrumbList, creativeWorkSchema } from "@/utils/seo";
// if you're using an adaptor in SSR mode, getStaticPaths wont work -> https://docs.astro.build/en/guides/routing/#modifying-the-slug-example-for-ssr
export const getStaticPaths = (async () => {
const allProjects = await getCollection("projects");
return allProjects.map((project) => ({
params: { slug: project.id },
props: { project },
}));
}) satisfies GetStaticPaths;
export type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { project } = Astro.props;
const { title, description, link, repo, tags, startDate } = project.data;
const path = `/projects/${project.id}/`;
const meta = {
description: description || `Project: ${title}`,
keywords: tags,
ogImage: `/og-image/projects/${project.id}.png`,
schema: [
breadcrumbList([
{ name: "Home", path: "/" },
{ name: "Projects", path: "/projects/" },
{ name: title, path },
]),
creativeWorkSchema({ title, description, path, url: link, repo, dateCreated: startDate, keywords: tags }),
],
title,
};
---
<PageLayout meta={meta}>
<Project as="h1" project={project} />
</PageLayout>
+18
View File
@@ -0,0 +1,18 @@
import { getCollection } from "astro:content";
import rss from "@astrojs/rss";
import { siteConfig } from "@/site.config";
export const GET = async () => {
const projects = await getCollection("projects");
return rss({
title: siteConfig.title,
description: siteConfig.description,
site: import.meta.env.SITE,
items: projects.map((project) => ({
title: project.data.title,
pubDate: project.data.endDate ?? project.data.startDate,
link: `projects/${project.id}/`,
})),
});
};
+19
View File
@@ -0,0 +1,19 @@
import rss from "@astrojs/rss";
import { getAllPosts } from "@/data/post";
import { siteConfig } from "@/site.config";
export const GET = async () => {
const posts = await getAllPosts();
return rss({
title: siteConfig.title,
description: siteConfig.description,
site: import.meta.env.SITE,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.publishDate,
link: post.data.externalUrl ?? `writing/${post.id}/`,
})),
});
};
+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="lucide: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>
+65
View File
@@ -0,0 +1,65 @@
---
import type { CollectionEntry } from "astro:content";
import type { GetStaticPaths, Page } from "astro";
import FormattedDate from "@/components/FormattedDate.astro";
import Pagination from "@/components/Paginator.astro";
import { getAllSpeaking } from "@/data/speaking";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
import { breadcrumbList, collectionPageSchema } from "@/utils/seo";
export const getStaticPaths = (async ({ paginate }) => {
const talks = await getAllSpeaking();
return paginate(talks.sort(collectionDateSort), { pageSize: 10 });
}) satisfies GetStaticPaths;
interface Props {
page: Page<CollectionEntry<"speaking">>;
}
const { page } = Astro.props;
const meta = {
description: "Talks and presentations I've given",
schema: [
breadcrumbList([
{ name: "Home", path: "/" },
{ name: "Speaking", path: "/speaking/" },
]),
collectionPageSchema({
name: "Speaking",
description: "Talks and presentations I've given",
path: page.url.current,
items: page.data.map((t) => ({ name: t.data.title, path: `/speaking/${t.id}/` })),
}),
],
title: "Speaking",
};
const paginationProps = {
...(page.url.prev && { prevUrl: { text: "← Previous Page", url: page.url.prev } }),
...(page.url.next && { nextUrl: { text: "Next Page →", url: page.url.next } }),
};
---
<PageLayout meta={meta}>
<h1 class="title mb-12">Speaking</h1>
<ul class="space-y-6">
{
page.data.map((talk) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr]">
<FormattedDate class="text-muted min-w-30 font-semibold" date={talk.data.publishDate} />
<div>
<h2 class="title text-base">
<a class="cactus-link" href={`/speaking/${talk.id}/`}>
{talk.data.title}
</a>
</h2>
{talk.data.event && <p class="text-muted text-sm">{talk.data.event}</p>}
</div>
</li>
))
}
</ul>
<Pagination {...paginationProps} />
</PageLayout>
+60
View File
@@ -0,0 +1,60 @@
---
import { getCollection, render } from "astro:content";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
import ExperienceTags from "@/components/ExperienceTags.astro";
import FormattedDate from "@/components/FormattedDate.astro";
import TagList from "@/components/TagList.astro";
import YouTube from "@/components/YouTube.astro";
import PageLayout from "@/layouts/Base.astro";
import { breadcrumbList, videoSchema } from "@/utils/seo";
export const getStaticPaths = (async () => {
const talks = await getCollection("speaking");
return talks.map((talk) => ({
params: { slug: talk.id },
props: { talk },
}));
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { talk } = Astro.props as Props;
const { Content } = await render(talk);
const { title, description, youtubeId, event, publishDate, experiences, tags } = talk.data;
const path = `/speaking/${talk.id}/`;
const ogImage = `/og-image/speaking/${talk.id}.png`;
const schema = [
breadcrumbList([
{ name: "Home", path: "/" },
{ name: "Speaking", path: "/speaking/" },
{ name: title, path },
]),
videoSchema({
title,
description,
path,
thumbnail: `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`,
uploadDate: publishDate,
embedUrl: `https://www.youtube.com/embed/${youtubeId}`,
contentUrl: `https://www.youtube.com/watch?v=${youtubeId}`,
}),
];
const meta = { description, keywords: tags, ogImage, schema, title };
---
<PageLayout meta={meta}>
<article data-pagefind-body>
<h1 class="title mb-2">{title}</h1>
<p class="text-muted mb-6">
<FormattedDate date={publishDate} />{event && ` · ${event}`}
</p>
<YouTube id={youtubeId} title={title} />
<div class="prose prose-sm prose-cactus mt-8 max-w-none">
<Content />
</div>
<TagList tags={tags} class="mt-8" />
{experiences.length > 0 && <div class="mt-6"><ExperienceTags experiences={experiences} /></div>}
</article>
</PageLayout>
+140
View File
@@ -0,0 +1,140 @@
---
import type { CollectionEntry } from "astro:content";
import type { GetStaticPaths, Page } from "astro";
import { Icon } from "astro-icon/components";
import PostPreview from "@/components/blog/PostPreview.astro";
import Pagination from "@/components/Paginator.astro";
import { getAllExperience } from "@/data/experience";
import { getAllPosts, groupPostsByYear } from "@/data/post";
import PageLayout from "@/layouts/Base.astro";
import { collectionDateSort } from "@/utils/date";
import { breadcrumbList, collectionPageSchema } from "@/utils/seo";
export const getStaticPaths = (async ({ paginate }) => {
const MAX_POSTS_PER_PAGE = 10;
const MAX_PINNED_POSTS = 3;
const allPosts = await getAllPosts();
const allPostsByDate = allPosts.sort(collectionDateSort);
const experiences = await getAllExperience();
const pinnedPosts = allPostsByDate
.values()
.filter((p) => p.data.pinned)
.take(MAX_PINNED_POSTS)
.toArray();
return paginate(allPostsByDate, {
pageSize: MAX_POSTS_PER_PAGE,
props: { experiences, pinnedPosts },
});
}) satisfies GetStaticPaths;
interface Props {
page: Page<CollectionEntry<"writing">>;
experiences: CollectionEntry<"experience">[];
pinnedPosts: CollectionEntry<"writing">[];
}
const { page, experiences, pinnedPosts } = Astro.props;
const meta = {
description: "Essays, notes and articles I've written",
schema: [
breadcrumbList([
{ name: "Home", path: "/" },
{ name: "Writing", path: "/writing/" },
]),
collectionPageSchema({
name: "Writing",
description: "Essays, notes and articles I've written",
path: page.url.current,
items: page.data.map((p) => ({ name: p.data.title, path: `/writing/${p.id}/` })),
}),
],
title: "Writing",
};
const paginationProps = {
...(page.url.prev && {
prevUrl: {
text: "← Previous Page",
url: page.url.prev,
},
}),
...(page.url.next && {
nextUrl: {
text: "Next Page →",
url: page.url.next,
},
}),
};
const groupedByYear = groupPostsByYear(page.data);
const descYearKeys = Object.keys(groupedByYear).sort((a, b) => +b - +a);
---
<PageLayout meta={meta}>
<div class="mb-12 flex items-center gap-3">
<h1 class="title">Writing</h1>
<a class="text-accent" href="/rss.xml" target="_blank">
<span class="sr-only">RSS feed</span>
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="lucide:rss" />
</a>
</div>
<div class="grid sm:grid-cols-[3fr_1fr] sm:gap-x-8 sm:gap-y-16">
<div>
{
pinnedPosts.length > 0 && (
<section class="mb-16">
<h2 class="title mb-6 text-xl">Pinned</h2>
<ul class="space-y-4" role="list">
{pinnedPosts.map((p) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr]">
<PostPreview post={p} />
</li>
))}
</ul>
</section>
)
}
{
descYearKeys.map((yearKey) => (
<section class="mb-16">
<h2 id={`year-${yearKey}`} class="title text-lg">
<span class="sr-only">Posts in</span>
{yearKey}
</h2>
<ul class="mt-5 space-y-4 text-start">
{groupedByYear[yearKey]?.map((p) => (
<li class="grid gap-1 sm:grid-cols-[auto_1fr] sm:[&_q]:col-start-2">
<PostPreview post={p} />
</li>
))}
</ul>
</section>
))
}
<Pagination {...paginationProps} />
</div>
{
!!experiences.length && (
<aside>
<h2 class="title mb-4 text-lg">Experience</h2>
<ul class="flex flex-col gap-2">
{experiences.map((exp) => (
<li>
<a class="cactus-link" href={`/experience/${exp.id}/`}>
{exp.data.title}
</a>
</li>
))}
</ul>
<span class="mt-4 block sm:text-end">
<a class="hover:text-link" href="/experience/">
View all <span aria-hidden="true">→</span>
<span class="sr-only">experience</span>
</a>
</span>
</aside>
)
}
</div>
</PageLayout>
+27
View File
@@ -0,0 +1,27 @@
---
import { render } from "astro:content";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
import { getAllPosts } from "@/data/post";
import PostLayout from "@/layouts/BlogPost.astro";
// if you're using an adaptor in SSR mode, getStaticPaths wont work -> https://docs.astro.build/en/guides/routing/#modifying-the-slug-example-for-ssr
export const getStaticPaths = (async () => {
const blogEntries = await getAllPosts();
// External (PESOS) articles link out to the publisher; no local detail page.
return blogEntries
.filter((post) => !post.data.externalUrl)
.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}) satisfies GetStaticPaths;
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { post } = Astro.props;
const { Content } = await render(post);
---
<PostLayout post={post}>
<Content />
</PostLayout>