mirror of
https://github.com/prdlk/prdlk.git
synced 2026-11-03 05:27:41 +00:00
feat(pages): add new site pages, routes, and OG image generation
This commit is contained in:
1 parent
8a191f65cc
commit
01eac43ecf
19 files changed
+1206
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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" },
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 &…".
|
||||
|
||||
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}`),
|
||||
],
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}/`,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@@ -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}/`,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user