diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..cac5b12 --- /dev/null +++ b/src/pages/404.astro @@ -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!", +}; +--- + + +

404 | Oops something went wrong

+

Please use the navigation to find your way back

+
diff --git a/src/pages/experience/[...slug].astro b/src/pages/experience/[...slug].astro new file mode 100644 index 0000000..d3e959c --- /dev/null +++ b/src/pages/experience/[...slug].astro @@ -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; + +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, +}; +--- + + + + +

{role ? `${role}, ${organization}` : organization}

+

+ {range}{location && ` · ${location}`} + { + url && ( + <> + {" · "} + + Website → + + + ) + } +

+ +
+ +
+ + { + content.projects.length > 0 && ( +
+

Projects

+ +
+ ) + } + + { + content.writing.length > 0 && ( +
+

Writing

+
    + {content.writing.map((p) => ( +
  • + +
  • + ))} +
+
+ ) + } + + { + content.speaking.length > 0 && ( +
+

Speaking

+ +
+ ) + } + + +
diff --git a/src/pages/experience/index.astro b/src/pages/experience/index.astro new file mode 100644 index 0000000..3e04091 --- /dev/null +++ b/src/pages/experience/index.astro @@ -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"}`; +--- + + +

Experience

+ +
+

Education

+

+ Virginia Commonwealth University
+ B.S. in Applied Mathematics +

+

+ 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. +

+
+
diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..273f52e --- /dev/null +++ b/src/pages/index.astro @@ -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)], +}; +--- + + +
+

About

+

+ I'm Prad Nukala — an engineer and founder building decentralized identity infrastructure. As + co-founder and CEO of Sonr, I'm + building a peer-to-peer identity and data network powered by decentralized identifiers (DIDs), + WebAuthn, and IPFS. +

+

+ I help shape the open standards behind user-owned identity as a working-group member at the + W3C (DIDs, + WebAuthn, WebAssembly) + 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. +

+

+ Find me on Github, + X, + LinkedIn, or + contact me directly. +

+
+ { + latestProjects.length > 0 && ( +
+

+ Projects +

+
    + {latestProjects.map((project) => ( +
  • + +
  • + ))} +
+ + See all projects + +
+ ) + } +
+

Writing

+
    + { + latestPosts.map((p) => ( +
  • + +
  • + )) + } +
+ + See all writing + +
+ { + talks.length > 0 && ( +
+

+ Speaking +

+ +
+ ) + } +
+

Honors & Recognition

+
    +
  • Guest Lecturer, Crypto Finance (15.492) — MIT Sloan School of Management
  • +
+
+
+

Standards & Organizations

+ +
+
diff --git a/src/pages/llms-full.txt.ts b/src/pages/llms-full.txt.ts new file mode 100644 index 0000000..4de3e11 --- /dev/null +++ b/src/pages/llms-full.txt.ts @@ -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" }, + }); diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts new file mode 100644 index 0000000..fc6cdc6 --- /dev/null +++ b/src/pages/llms.txt.ts @@ -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" }, + }); diff --git a/src/pages/og-image/[...slug].png.ts b/src/pages/og-image/[...slug].png.ts new file mode 100644 index 0000000..6dbeaa6 --- /dev/null +++ b/src/pages/og-image/[...slug].png.ts @@ -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]; +} diff --git a/src/pages/og-image/_cacheUtil.ts b/src/pages/og-image/_cacheUtil.ts new file mode 100644 index 0000000..c27e17b --- /dev/null +++ b/src/pages/og-image/_cacheUtil.ts @@ -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); +} diff --git a/src/pages/og-image/_ogMarkup.ts b/src/pages/og-image/_ogMarkup.ts new file mode 100644 index 0000000..bd075d0 --- /dev/null +++ b/src/pages/og-image/_ogMarkup.ts @@ -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; +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}`), + ], + ); diff --git a/src/pages/projects/[...page].astro b/src/pages/projects/[...page].astro new file mode 100644 index 0000000..775c2b8 --- /dev/null +++ b/src/pages/projects/[...page].astro @@ -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>; +} + +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, + }, + }), +}; +--- + + +
+

+ Projects + RSS feed + +

+
    + { + page.data.map((project) => ( +
  • + +
  • + )) + } +
+ +
+
diff --git a/src/pages/projects/[...slug].astro b/src/pages/projects/[...slug].astro new file mode 100644 index 0000000..74a58de --- /dev/null +++ b/src/pages/projects/[...slug].astro @@ -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; + +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, +}; +--- + + + + diff --git a/src/pages/projects/rss.xml.ts b/src/pages/projects/rss.xml.ts new file mode 100644 index 0000000..0ca1e88 --- /dev/null +++ b/src/pages/projects/rss.xml.ts @@ -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}/`, + })), + }); +}; diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts new file mode 100644 index 0000000..60e126c --- /dev/null +++ b/src/pages/rss.xml.ts @@ -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}/`, + })), + }); +}; diff --git a/src/pages/skills/[skill].astro b/src/pages/skills/[skill].astro new file mode 100644 index 0000000..5fa6bf7 --- /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/pages/speaking/[...page].astro b/src/pages/speaking/[...page].astro new file mode 100644 index 0000000..76bdb9a --- /dev/null +++ b/src/pages/speaking/[...page].astro @@ -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>; +} + +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 } }), +}; +--- + + +

Speaking

+
    + { + page.data.map((talk) => ( +
  • + +
    +

    + + {talk.data.title} + +

    + {talk.data.event &&

    {talk.data.event}

    } +
    +
  • + )) + } +
+ +
diff --git a/src/pages/speaking/[...slug].astro b/src/pages/speaking/[...slug].astro new file mode 100644 index 0000000..3dec36b --- /dev/null +++ b/src/pages/speaking/[...slug].astro @@ -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; + +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 }; +--- + + +
+

{title}

+

+ {event && ` · ${event}`} +

+ +
+ +
+ + {experiences.length > 0 &&
} +
+
diff --git a/src/pages/writing/[...page].astro b/src/pages/writing/[...page].astro new file mode 100644 index 0000000..bd3c67b --- /dev/null +++ b/src/pages/writing/[...page].astro @@ -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>; + 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); +--- + + +
+

Writing

+ + RSS feed + +
+
+
+ { + pinnedPosts.length > 0 && ( +
+

Pinned

+
    + {pinnedPosts.map((p) => ( +
  • + +
  • + ))} +
+
+ ) + } + { + descYearKeys.map((yearKey) => ( +
+

+ Posts in + {yearKey} +

+
    + {groupedByYear[yearKey]?.map((p) => ( +
  • + +
  • + ))} +
+
+ )) + } + +
+ { + !!experiences.length && ( + + ) + } +
+
diff --git a/src/pages/writing/[...slug].astro b/src/pages/writing/[...slug].astro new file mode 100644 index 0000000..8128fc0 --- /dev/null +++ b/src/pages/writing/[...slug].astro @@ -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; + +const { post } = Astro.props; +const { Content } = await render(post); +--- + + + +