From bff9f9f999214210ca40657e6fae55bae172e545 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Wed, 19 Aug 2026 14:11:46 -0400 Subject: [PATCH] feat(data): add content query helpers and LLMS builder --- src/data/AGENTS.md | 27 ++++++ src/data/experience.ts | 22 +++++ src/data/llms.ts | 187 +++++++++++++++++++++++++++++++++++++++++ src/data/post.ts | 15 ++++ src/data/speaking.ts | 8 ++ src/data/tags.ts | 54 ++++++++++++ 6 files changed, 313 insertions(+) create mode 100644 src/data/AGENTS.md create mode 100644 src/data/experience.ts create mode 100644 src/data/llms.ts create mode 100644 src/data/post.ts create mode 100644 src/data/speaking.ts create mode 100644 src/data/tags.ts diff --git a/src/data/AGENTS.md b/src/data/AGENTS.md new file mode 100644 index 0000000..b95c626 --- /dev/null +++ b/src/data/AGENTS.md @@ -0,0 +1,27 @@ + + + +# data + +## Purpose +Thin query helpers over Content Collections — draft filtering, sorting, and the experience-taxonomy aggregation. + +## Key Files +| File | Description | +|------|-------------| +| `post.ts` | `getAllPosts()` (returns `CollectionEntry<"writing">`, drafts filtered in prod) and `groupPostsByYear()`. **Naming quirk:** file/functions say "post" but operate on the `writing` collection. | +| `experience.ts` | `getAllExperience()` (sorted by `startDate` desc) and `getContentForExperience(id)` which gathers all `projects`/`writing`/`speaking` entries whose `experiences` include `id`. | +| `speaking.ts` | `getAllSpeaking()` (drafts filtered in prod). | + +## For AI Agents + +### Working In This Directory +- Draft filtering uses `import.meta.env.PROD ? !data.draft : true` — drafts are visible in dev, hidden in production. +- `getContentForExperience` matches refs via `entry.data.experiences.some((ref) => ref.id === id)`. + +## Dependencies + +### Internal +- `astro:content` (`getCollection`). Consumed by pages in `src/pages/` and the homepage. + + diff --git a/src/data/experience.ts b/src/data/experience.ts new file mode 100644 index 0000000..4b0e1ae --- /dev/null +++ b/src/data/experience.ts @@ -0,0 +1,22 @@ +import { type CollectionEntry, getCollection } from "astro:content"; + +/** All experiences, most recent (by startDate) first. */ +export async function getAllExperience(): Promise[]> { + const entries = await getCollection("experience"); + return entries.sort((a, b) => b.data.startDate.valueOf() - a.data.startDate.valueOf()); +} + +const hasExperience = ( + entry: { data: { experiences: { id: string }[] } }, + id: string, +): boolean => entry.data.experiences.some((ref) => ref.id === id); + +/** Every project, writing & talk filed under a given experience id. */ +export async function getContentForExperience(id: string) { + const [projects, writing, speaking] = await Promise.all([ + getCollection("projects", (e) => hasExperience(e, id)), + getCollection("writing", (e) => (import.meta.env.PROD ? !e.data.draft : true) && hasExperience(e, id)), + getCollection("speaking", (e) => (import.meta.env.PROD ? !e.data.draft : true) && hasExperience(e, id)), + ]); + return { projects, writing, speaking }; +} diff --git a/src/data/llms.ts b/src/data/llms.ts new file mode 100644 index 0000000..6ff2336 --- /dev/null +++ b/src/data/llms.ts @@ -0,0 +1,187 @@ +import type { CollectionEntry } from "astro:content"; +import { getCollection } from "astro:content"; +import { getAllExperience } from "@/data/experience"; +import { getAllPosts } from "@/data/post"; +import { getAllSpeaking } from "@/data/speaking"; + +/** Builders for /llms.txt (index) and /llms-full.txt (full content), per https://llmstxt.org */ + +const absUrl = (path: string) => new URL(path, import.meta.env.SITE).href; + +const isoDate = (date: Date) => date.toISOString().slice(0, 10); + +const introHeading = "# Prad Nukala"; + +const introSummary = + "> Engineer & founder. Co-founder/CEO of Sonr ($4.7M raised). Built Layer-1 + full cryptography stack (ZKP/MPC), gasless onboarding, and led high-ownership teams. Optimized for autonomous, high-speed execution."; + +const yearRange = (start: Date, end?: Date) => + `${start.getFullYear()}–${end ? end.getFullYear() : "Present"}`; + +async function getAllContent() { + const [experience, projects, writing, speaking] = await Promise.all([ + getAllExperience(), + getCollection("projects").then((entries) => + entries.sort((a, b) => b.data.startDate.valueOf() - a.data.startDate.valueOf()), + ), + getAllPosts().then((entries) => + entries.sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()), + ), + getAllSpeaking().then((entries) => + entries.sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()), + ), + ]); + return { experience, projects, writing, speaking }; +} + +const writingUrl = (post: CollectionEntry<"writing">) => + post.data.externalUrl ?? absUrl(`/writing/${post.id}/`); + +/** Curated markdown index of the whole site. */ +export async function buildLlmsIndex(): Promise { + const { experience, projects, writing, speaking } = await getAllContent(); + + const lines: string[] = [ + introHeading, + "", + introSummary, + "", + "## Experience", + "", + ...experience.map((exp) => { + const { organization, role, startDate, endDate } = exp.data; + const detail = [role, organization].filter(Boolean).join(", "); + return `- [${exp.data.title}](${absUrl(`/experience/${exp.id}/`)}): ${detail} (${yearRange(startDate, endDate)})`; + }), + "", + "## Projects", + "", + ...projects.map((project) => { + const { title, description, startDate, endDate } = project.data; + const summary = description ? ` ${description}` : ""; + return `- [${title}](${absUrl(`/projects/${project.id}/`)}):${summary} (${yearRange(startDate, endDate)})`; + }), + "", + "## Writing", + "", + ...writing.map((post) => { + const { title, description, publisher, publishDate } = post.data; + const via = publisher ? ` — via ${publisher}` : ""; + return `- [${title}](${writingUrl(post)}): ${description}${via} (${isoDate(publishDate)})`; + }), + "", + "## Speaking", + "", + ...speaking.map((talk) => { + const { title, description, event, publishDate } = talk.data; + const at = event ? ` — ${event}` : ""; + return `- [${title}](${absUrl(`/speaking/${talk.id}/`)}): ${description}${at} (${isoDate(publishDate)})`; + }), + "", + "## Optional", + "", + `- [Full content](${absUrl("/llms-full.txt")}): Every page's full text in one file`, + `- [RSS feed](${absUrl("/rss.xml")})`, + "", + ]; + + return lines.join("\n"); +} + +function section(heading: string, blocks: string[]): string[] { + return blocks.length ? [`## ${heading}`, "", ...blocks] : []; +} + +function entryBlock(header: string, meta: string[], body: string | undefined): string { + const content = body?.trim(); + return [`### ${header}`, "", ...meta, ...(content ? ["", content] : []), ""].join("\n"); +} + +/** Full markdown body of every entry, for direct LLM ingestion. */ +export async function buildLlmsFull(): Promise { + const { experience, projects, writing, speaking } = await getAllContent(); + + const lines: string[] = [ + `${introHeading} — full content`, + "", + introSummary, + "", + ...section( + "Experience", + experience.map((exp) => { + const { organization, role, location, url, description, tags, startDate, endDate } = + exp.data; + return entryBlock( + exp.data.title, + [ + `- URL: ${absUrl(`/experience/${exp.id}/`)}`, + `- Organization: ${organization}`, + ...(role ? [`- Role: ${role}`] : []), + ...(location ? [`- Location: ${location}`] : []), + ...(url ? [`- Website: ${url}`] : []), + `- Period: ${yearRange(startDate, endDate)}`, + ...(tags.length ? [`- Skills: ${tags.join(", ")}`] : []), + ...(description ? [`- Summary: ${description}`] : []), + ], + exp.body, + ); + }), + ), + ...section( + "Projects", + projects.map((project) => { + const { description, link, repo, tags, startDate, endDate } = project.data; + return entryBlock( + project.data.title, + [ + `- URL: ${absUrl(`/projects/${project.id}/`)}`, + ...(link ? [`- Link: ${link}`] : []), + ...(repo ? [`- Repository: ${repo}`] : []), + `- Period: ${yearRange(startDate, endDate)}`, + ...(tags.length ? [`- Skills: ${tags.join(", ")}`] : []), + ...(description ? [`- Summary: ${description}`] : []), + ], + project.body, + ); + }), + ), + ...section( + "Writing", + writing.map((post) => { + const { description, publisher, tags, publishDate, updatedDate } = post.data; + return entryBlock( + post.data.title, + [ + `- URL: ${writingUrl(post)}`, + ...(publisher ? [`- Publisher: ${publisher}`] : []), + `- Published: ${isoDate(publishDate)}`, + ...(updatedDate ? [`- Updated: ${isoDate(updatedDate)}`] : []), + ...(tags.length ? [`- Tags: ${tags.join(", ")}`] : []), + `- Summary: ${description}`, + ], + post.body, + ); + }), + ), + ...section( + "Speaking", + speaking.map((talk) => { + const { description, event, youtubeId, tags, publishDate } = talk.data; + return entryBlock( + talk.data.title, + [ + `- URL: ${absUrl(`/speaking/${talk.id}/`)}`, + ...(event ? [`- Event: ${event}`] : []), + `- Video: https://www.youtube.com/watch?v=${youtubeId}`, + `- Date: ${isoDate(publishDate)}`, + ...(tags.length ? [`- Tags: ${tags.join(", ")}`] : []), + `- Summary: ${description}`, + ], + talk.body, + ); + }), + ), + ]; + + return lines.join("\n"); +} diff --git a/src/data/post.ts b/src/data/post.ts new file mode 100644 index 0000000..57098d6 --- /dev/null +++ b/src/data/post.ts @@ -0,0 +1,15 @@ +import { type CollectionEntry, getCollection } from "astro:content"; + +/** filter out draft posts based on the environment */ +export async function getAllPosts(): Promise[]> { + return await getCollection("writing", ({ data }) => { + return import.meta.env.PROD ? !data.draft : true; + }); +} + +/** groups posts by year, using the year as the key + * Note: This function doesn't filter draft posts, pass it the result of getAllPosts above to do so. + */ +export function groupPostsByYear(posts: CollectionEntry<"writing">[]) { + return Object.groupBy(posts, (post) => post.data.publishDate.getFullYear().toString()); +} diff --git a/src/data/speaking.ts b/src/data/speaking.ts new file mode 100644 index 0000000..1671ae8 --- /dev/null +++ b/src/data/speaking.ts @@ -0,0 +1,8 @@ +import { type CollectionEntry, getCollection } from "astro:content"; + +/** filter out draft talks in production */ +export async function getAllSpeaking(): Promise[]> { + return await getCollection("speaking", ({ data }) => { + return import.meta.env.PROD ? !data.draft : true; + }); +} diff --git a/src/data/tags.ts b/src/data/tags.ts new file mode 100644 index 0000000..5c634cc --- /dev/null +++ b/src/data/tags.ts @@ -0,0 +1,54 @@ +import { type CollectionEntry, getCollection } from "astro:content"; + +/** URL-safe slug for a skill/tag (tags are already lowercased by the schema). */ +export const slugifyTag = (tag: string) => + tag + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + +const draftFilter = ({ data }: { data: { draft?: boolean } }) => + import.meta.env.PROD ? !data.draft : true; + +/** All tag-bearing content across the four collections (drafts filtered in prod). */ +export async function getTaggedCollections() { + const [projects, experience, writing, speaking] = await Promise.all([ + getCollection("projects"), + getCollection("experience"), + getCollection("writing", draftFilter), + getCollection("speaking", draftFilter), + ]); + return { projects, experience, writing, speaking }; +} + +export type TaggedCollections = Awaited>; + +type AnyTagged = CollectionEntry<"projects" | "experience" | "writing" | "speaking">; +const tagsOf = (entry: AnyTagged) => entry.data.tags ?? []; + +/** Map of slug → { label, count } across every collection, for the tag index. */ +export function getTagIndex(cols: TaggedCollections) { + const index = new Map(); + for (const col of Object.values(cols)) { + for (const entry of col) { + for (const tag of tagsOf(entry)) { + const slug = slugifyTag(tag); + const cur = index.get(slug) ?? { label: tag, count: 0 }; + cur.count += 1; + index.set(slug, cur); + } + } + } + return index; +} + +/** Every entry, grouped by collection, that carries the given tag label. */ +export function filterByTag(cols: TaggedCollections, label: string) { + const has = (e: AnyTagged) => tagsOf(e).includes(label); + return { + projects: cols.projects.filter(has), + experience: cols.experience.filter(has), + writing: cols.writing.filter(has), + speaking: cols.speaking.filter(has), + }; +}