diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..3402a0d --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +brand + +## Users + +Peer engineers evaluating Kaylee's craft or c15t, recruiters sizing up her scope, founders working in privacy and consent, and AI agents summarizing her work. Visitors skim quickly and need to understand what she builds, see proof of the work, and remember the site as distinctly hers. + +## Product Purpose + +Kaylee's personal site explains and demonstrates her work as a founding engineer at Inth building the open-source compliance stack: c15t for consent, DSAR for data rights, Cookiebench for performance, and Leadtype for agent-readable docs. Each surface should reinforce the thesis that compliance belongs in code. + +## Brand Personality + +Precise, playful, and design-minded. The tone is clean, warm, and self-assured. Personality comes through specific earned details rather than labels or hype. + +## Anti-references + +Generic AI-generated portfolio templates, clones of burnedchris.com, try-hard quirk, and corporate or sterile agency styling. Avoid static metrics in prose, self-mythologizing career narration, disconnected project lists, and decorative personality labels. + +## Design Principles + +1. Show, do not announce. Use real artifacts and earned details as proof. +2. Practice what you preach. The site itself should demonstrate privacy, accessibility, and performance. +3. Let the work carry the weight. Prefer live evidence and shipped work to adjectives. +4. Connect every project to the same causal thesis: compliance belongs in code. +5. Use whimsy sparingly and execute it well. + +## Accessibility & Inclusion + +Meet WCAG 2.1 AA. Keep the purple accent legible in both themes, respect color-scheme and reduced-motion preferences, provide keyboard navigation and visible focus states, and make controls comfortable on touch screens. diff --git a/content/blog/code-doesnt-have-to-ship.md b/content/blog/code-doesnt-have-to-ship.md new file mode 100644 index 0000000..ff6f6c1 --- /dev/null +++ b/content/blog/code-doesnt-have-to-ship.md @@ -0,0 +1,61 @@ +--- +title: "Code doesn't have to ship" +description: "Coding agents make more engineering questions cheap enough to test." +publishedAt: 2026-07-12 +tags: + - AI + - engineering + - experiments +draft: false +--- +In April, a conversation with [https://x.com/tannerlinsley](https://x.com/tannerlinsley) at [AI Engineer Miami](https://www.ai.engineer/miami) and [React Miami](https://www.reactmiami.com/) left me with a question: what if we used coding models to test new shapes for a product, not just clear its existing backlog? + +I had somewhere to test it. For c15t v3, I was working on the server-rendered path for consent experiences. + +Client-side c15t has a straightforward request path: + +```text +Client → c15t backend → Client +``` + +Server-side rendering lets c15t use the framework to prepare the geo- and locale-specific experience before the page reaches the browser. In Next.js, that path looked like this: + +```text +Client → Next.js server → c15t backend → Next.js server → Client +``` + +This moved the work server-side, but the Next.js server still had to call the c15t backend before it could respond. That put the latency between those servers on the critical path. + +I wanted to know whether the manifest system I built for scripts in v2 could also describe the data needed to render a consent experience. My hypothesis was that we could cache that manifest at the CDN edge, then let the Next.js server combine it with the request's geography and locale to produce the response itself: + +```text +Client → Next.js server → Client +``` + +That would keep the benefits of server-side rendering while removing the trip to the c15t backend. The question was whether the performance improvement would justify moving more logic into the manifest system. + +Proving it by hand would have meant giving an uncertain direction real roadmap time. That is usually where an experiment loses to work with a clearer payoff. + +Instead, I wrote down the hypothesis, the rough implementation, and the benchmark that would decide whether it was worth pursuing. I set an agent loop running, then spent the day cleaning my apartment. + +The experiment worked. With the manifest cached at the CDN edge, the Next.js server could produce the response without waiting for the c15t backend. The request was no longer exposed to the latency between those servers, and the improvement was large enough to keep investigating. + +I started using the same approach elsewhere in c15t and across a few of my other projects. Some ideas worked. Others failed miserably. + +The failures were useful. While a model explored a dead end, I worked on something else. I still had to inspect the implementation and decide what its result proved, but I did not have to build every branch myself. + +Theo described the distinction neatly: code can be useful without being merged. + +[https://x.com/theo/status/2074094418798485814](https://x.com/theo/status/2074094418798485814) + +That is what makes this different from asking a coding agent to clear a backlog. The task is not “implement this feature.” It is “produce enough evidence to decide whether this idea deserves to become a feature.” + +A prototype gives an idea shape. An experiment answers a question. Coding agents make both cheaper, but only the second starts with a hypothesis and a way to prove it wrong. + +More possible implementations do not create more time to evaluate them. Every branch still asks for attention. Guillermo Rauch makes the other half of the argument: greater implementation capacity makes engineering judgment more important, not less. + +[https://x.com/rauchg/status/2070923036988248248](https://x.com/rauchg/status/2070923036988248248) + +That is why I start with a question and a way to measure the answer. Without both, cheap code just creates more code to review. + +The code does not need to ship. It just needs to make the next decision clearer. diff --git a/lib/agent-markdown.ts b/lib/agent-markdown.ts index 8f037fb..3303aec 100644 --- a/lib/agent-markdown.ts +++ b/lib/agent-markdown.ts @@ -1,5 +1,6 @@ import { getAllExperience } from "@/lib/get-all-experience"; import { getAllProjects } from "@/lib/get-all-projects"; +import { getBlogPosts } from "@/lib/get-blog-posts"; import { getSiteContent } from "@/lib/get-site-content"; import { renderPortfolioMarkdown } from "@/lib/render-portfolio-markdown"; import { pageSeo, type SeoKey } from "@/lib/seo"; @@ -12,6 +13,7 @@ const PAGE_PATHS: Record = { home: { path: "/", mdPath: "/index.md" }, about: { path: "/about", mdPath: "/about.md" }, projects: { path: "/projects", mdPath: "/projects.md" }, + blog: { path: "/blog", mdPath: "/blog.md" }, connect: { path: "/connect", mdPath: "/connect.md" }, }; @@ -143,6 +145,20 @@ function connectBody(): string { return lines.join("\n").trimEnd(); } +async function blogBody(base: string): Promise { + const posts = await getBlogPosts(); + const lines = ["# Blog", ""]; + for (const post of posts) { + lines.push( + `## [${post.data.title}](${base}/blog/${post.id}.md)`, + "", + post.data.description, + "" + ); + } + return lines.join("\n").trimEnd(); +} + /** Full Markdown mirror of an HTML page: frontmatter + body + sitemap link. */ export async function renderPageMarkdown( key: PageKey, @@ -159,6 +175,8 @@ export async function renderPageMarkdown( body = await aboutBody(); } else if (key === "projects") { body = await projectsBody(); + } else if (key === "blog") { + body = await blogBody(base); } else { body = connectBody(); } @@ -177,6 +195,7 @@ export function renderNotFoundMarkdown(baseUrl: string): string { `- [Home](${base}/) — [markdown](${base}/index.md)`, `- [About](${base}/about) — [markdown](${base}/about.md)`, `- [Projects](${base}/projects) — [markdown](${base}/projects.md)`, + `- [Blog](${base}/blog) — [markdown](${base}/blog.md)`, `- [Connect](${base}/connect) — [markdown](${base}/connect.md)`, "", `See [the full sitemap](${base}/sitemap.md).`, @@ -196,6 +215,7 @@ export function renderLlmsIndex(baseUrl: string): string { `- [Home](${base}/index.md): Overview, current work, and selected projects`, `- [About](${base}/about.md): Bio, experience, and appearances`, `- [Projects](${base}/projects.md): Open-source work — c15t, Cookiebench, DSAR, Leadtype, Joyful`, + `- [Blog](${base}/blog.md): Notes on compliance, developer tooling, and software craft`, `- [Connect](${base}/connect.md): Social and contact links`, "", "## More", @@ -211,9 +231,10 @@ export async function renderLlmsFull(): Promise { } /** sitemap.md — Markdown sitemap mirroring the site hierarchy. */ -export function renderSitemapMarkdown(baseUrl: string): string { +export async function renderSitemapMarkdown(baseUrl: string): Promise { const base = normalizeBase(baseUrl); const dateModified = new Date().toISOString(); + const posts = await getBlogPosts(); return `${[ "---", `title: "Sitemap — ${personConfig.name}"`, @@ -228,8 +249,13 @@ export function renderSitemapMarkdown(baseUrl: string): string { `- [Home](${base}/) — [markdown](${base}/index.md)`, `- [About](${base}/about) — [markdown](${base}/about.md)`, `- [Projects](${base}/projects) — [markdown](${base}/projects.md)`, + `- [Blog](${base}/blog) — [markdown](${base}/blog.md)`, `- [Connect](${base}/connect) — [markdown](${base}/connect.md)`, `- [The Crate](${base}/records) — [markdown](${base}/records.md)`, + ...posts.map( + (post) => + `- [${post.data.title}](${base}/blog/${post.id}) — [markdown](${base}/blog/${post.id}.md)` + ), "", "## Agent resources", "", diff --git a/lib/get-blog-posts.ts b/lib/get-blog-posts.ts new file mode 100644 index 0000000..63505e5 --- /dev/null +++ b/lib/get-blog-posts.ts @@ -0,0 +1,11 @@ +import { type CollectionEntry, getCollection } from "astro:content"; + +export type BlogPost = CollectionEntry<"blog">; + +export async function getBlogPosts(): Promise { + const posts = await getCollection("blog", ({ data }) => !data.draft); + + return posts.sort( + (a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime() + ); +} diff --git a/lib/rich-markdown.test.ts b/lib/rich-markdown.test.ts new file mode 100644 index 0000000..bd42246 --- /dev/null +++ b/lib/rich-markdown.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeXProfileTitle } from "./rich-markdown"; + +describe("normalizeXProfileTitle", () => { + test("removes an X suffix when the handle uses display casing", () => { + expect( + normalizeXProfileTitle( + "Christopher Burns (@BurnedChris) on X", + "burnedchris" + ) + ).toBe("Christopher Burns"); + }); + + test("removes an X suffix when the handle casing already matches", () => { + expect( + normalizeXProfileTitle( + "Tanner Linsley (@tannerlinsley) on X", + "tannerlinsley" + ) + ).toBe("Tanner Linsley"); + }); + + test("preserves titles belonging to a different handle", () => { + expect( + normalizeXProfileTitle( + "Christopher Burns (@someoneelse) on X", + "burnedchris" + ) + ).toBe("Christopher Burns (@someoneelse) on X"); + }); + + test("handles missing metadata", () => { + expect(normalizeXProfileTitle(undefined, "burnedchris")).toBeUndefined(); + }); +}); diff --git a/lib/rich-markdown.ts b/lib/rich-markdown.ts new file mode 100644 index 0000000..9ff32cc --- /dev/null +++ b/lib/rich-markdown.ts @@ -0,0 +1,573 @@ +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Marked, type Token, type Tokens } from "marked"; + +interface LinkPreview { + description?: string; + image?: string; + kind: "link" | "profile" | "tweet"; + profileUrl?: string; + site: string; + title: string; + tweetHtml?: string; +} + +interface OEmbedResponse { + author_name?: string; + author_url?: string; + html?: string; +} + +const EXTERNAL_URL_REGEX = /^https?:\/\//; +const TITLE_TAG_REGEX = /]*>([\s\S]*?)<\/title>/i; +const TWEET_BODY_REGEX = /]*>([\s\S]*?)<\/p>/i; +const WWW_PREFIX_REGEX = /^www\./; +const BR_TAG_REGEX = //gi; +const PROFILE_IMAGE_URL_REGEX = + /https:\/\/pbs\.twimg\.com\/profile_images\/[^"\\]+/i; +const AVATAR_CACHE_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 7; +const AVATAR_CACHE_DIRECTORY = join( + process.cwd(), + ".astro", + "blog-avatar-cache" +); + +const PROFILE_NAMES: Record = { + burnedchris: "Christopher Burns", + kaylee_dev: "Kaylee Williams", + tannerlinsley: "Tanner Linsley", +}; +const previewCache = new Map>(); +const avatarCache = new Map>(); + +const ENTITY_MAP: Record = { + "&": "&", + "'": "'", + "'": "'", + ">": ">", + "<": "<", + """: '"', +}; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"]/g, (character) => { + const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + }; + return entities[character] ?? character; + }); +} + +function decodeEntities(value: string): string { + return value.replace( + /&(amp|apos|#39|gt|lt|quot);/g, + (entity) => ENTITY_MAP[entity] ?? entity + ); +} + +function plainText(value: string): string { + return decodeEntities(value.replace(/<[^>]+>/g, "").trim()); +} + +export function normalizeXProfileTitle( + title: string | undefined, + handle: string +): string | undefined { + if (!title) { + return; + } + const escapedHandle = handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const suffix = new RegExp(`\\s+\\(@${escapedHandle}\\)\\s+on X$`, "i"); + return title.replace(suffix, "").trim() || undefined; +} + +function metaContent(html: string, key: string): string | undefined { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const patterns = [ + new RegExp( + `]+(?:property|name)=["']${escapedKey}["'][^>]+content=["']([^"']+)["'][^>]*>`, + "i" + ), + new RegExp( + `]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${escapedKey}["'][^>]*>`, + "i" + ), + ]; + + for (const pattern of patterns) { + const match = html.match(pattern)?.[1]; + if (match) { + return plainText(match); + } + } + return; +} + +function fetchWithTimeout(url: string): Promise { + return fetch(url, { + headers: { "User-Agent": "kaylee.dev link preview" }, + signal: AbortSignal.timeout(3500), + }); +} + +async function responseDataUrl( + response: Response +): Promise { + if (!response.ok) { + return; + } + const contentType = response.headers.get("content-type") ?? "image/jpeg"; + if (!contentType.startsWith("image/")) { + return; + } + const bytes = await response.arrayBuffer(); + if (bytes.byteLength > 200_000) { + return; + } + return `data:${contentType};base64,${Buffer.from(bytes).toString("base64")}`; +} + +function avatarCachePath(handle: string): string { + const safeHandle = handle.toLowerCase().replace(/[^a-z0-9_-]/g, "_"); + return join(AVATAR_CACHE_DIRECTORY, `${safeHandle}.txt`); +} + +async function readCachedAvatar(handle: string): Promise { + try { + const path = avatarCachePath(handle); + const file = await stat(path); + if (Date.now() - file.mtimeMs > AVATAR_CACHE_MAX_AGE_MS) { + return; + } + return await readFile(path, "utf8"); + } catch { + return; + } +} + +async function fetchAvatar(handle: string): Promise { + const cached = await readCachedAvatar(handle); + if (cached) { + return cached; + } + + let image: string | undefined; + try { + const response = await fetchWithTimeout( + `https://unavatar.io/x/${encodeURIComponent(handle)}?fallback=false` + ); + image = await responseDataUrl(response); + } catch { + image = undefined; + } + + if (!image) { + try { + const profileResponse = await fetchWithTimeout(`https://x.com/${handle}`); + const profileHtml = profileResponse.ok + ? await profileResponse.text() + : ""; + const imageUrl = profileHtml.match(PROFILE_IMAGE_URL_REGEX)?.[0]; + if (imageUrl) { + image = await responseDataUrl(await fetchWithTimeout(imageUrl)); + } + } catch { + image = undefined; + } + } + + if (image) { + try { + await mkdir(AVATAR_CACHE_DIRECTORY, { recursive: true }); + await writeFile(avatarCachePath(handle), image, "utf8"); + } catch { + // A read-only build filesystem still gets the in-memory cached image. + } + } + return image; +} + +function avatarDataUrl(handle: string): Promise { + const key = handle.toLowerCase(); + const cached = avatarCache.get(key); + if (cached) { + return cached; + } + const avatar = fetchAvatar(key); + avatarCache.set(key, avatar); + return avatar; +} + +function parseXUrl( + url: URL +): + | { handle: string; kind: "profile" } + | { handle: string; id: string; kind: "tweet" } + | undefined { + if ( + !["x.com", "www.x.com", "twitter.com", "www.twitter.com"].includes( + url.hostname + ) + ) { + return; + } + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length === 1) { + return { handle: parts[0], kind: "profile" }; + } + if (parts.length >= 3 && parts[1] === "status") { + return { handle: parts[0], id: parts[2], kind: "tweet" }; + } + return; +} + +async function profilePreview( + handle: string, + linkLabel: string +): Promise { + const explicitLabel = linkLabel.startsWith("http") ? undefined : linkLabel; + const image = avatarDataUrl(handle); + let resolvedName: string | undefined; + if (!explicitLabel) { + try { + const response = await fetchWithTimeout(`https://x.com/${handle}`); + const html = response.ok ? await response.text() : ""; + resolvedName = normalizeXProfileTitle( + metaContent(html, "og:title"), + handle + ); + } catch { + resolvedName = undefined; + } + } + return { + image: await image, + kind: "profile", + profileUrl: `https://x.com/${handle}`, + site: `@${handle}`, + title: + explicitLabel || + resolvedName || + PROFILE_NAMES[handle.toLowerCase()] || + `@${handle}`, + }; +} + +async function tweetPreview(url: string, handle: string): Promise { + try { + const endpoint = new URL("https://publish.twitter.com/oembed"); + endpoint.searchParams.set("url", url); + endpoint.searchParams.set("dnt", "true"); + endpoint.searchParams.set("omit_script", "true"); + const response = await fetchWithTimeout(endpoint.toString()); + if (!response.ok) { + throw new Error("X did not return this post"); + } + const data = (await response.json()) as OEmbedResponse; + const tweetHtml = data.html?.match(TWEET_BODY_REGEX)?.[1]; + const resolvedHandle = + data.author_url?.split("/").filter(Boolean).at(-1) || handle; + return { + image: await avatarDataUrl(resolvedHandle), + kind: "tweet", + profileUrl: data.author_url || `https://x.com/${resolvedHandle}`, + site: `@${resolvedHandle}`, + title: + data.author_name || + PROFILE_NAMES[resolvedHandle] || + `@${resolvedHandle}`, + tweetHtml, + }; + } catch { + return { + image: await avatarDataUrl(handle), + kind: "tweet", + profileUrl: `https://x.com/${handle}`, + site: `@${handle}`, + title: PROFILE_NAMES[handle.toLowerCase()] || `@${handle}`, + }; + } +} + +async function websitePreview( + url: URL, + linkLabel: string +): Promise { + const fallbackTitle = linkLabel.startsWith("http") + ? url.hostname.replace(WWW_PREFIX_REGEX, "") + : linkLabel; + try { + const response = await fetchWithTimeout(url.toString()); + if (!response.ok) { + throw new Error("Preview request failed"); + } + const html = await response.text(); + const rawTitle = + metaContent(html, "og:title") || + plainText(html.match(TITLE_TAG_REGEX)?.[1] ?? ""); + return { + description: + metaContent(html, "og:description") || metaContent(html, "description"), + kind: "link", + site: + metaContent(html, "og:site_name") || + url.hostname.replace(WWW_PREFIX_REGEX, ""), + title: rawTitle || fallbackTitle, + }; + } catch { + return { + kind: "link", + site: url.hostname.replace(WWW_PREFIX_REGEX, ""), + title: fallbackTitle, + }; + } +} + +function createPreview(href: string, label: string): Promise { + const url = new URL(href); + const xLink = parseXUrl(url); + if (xLink?.kind === "profile") { + return profilePreview(xLink.handle, label); + } + if (xLink?.kind === "tweet") { + return tweetPreview(url.toString(), xLink.handle); + } + return websitePreview(url, label); +} + +function cachedPreview(href: string, label: string): Promise { + const cached = previewCache.get(href); + if (cached) { + return cached; + } + const preview = createPreview(href, label).catch((error: unknown) => { + previewCache.delete(href); + throw error; + }); + previewCache.set(href, preview); + return preview; +} + +function standaloneLink(token: Tokens.Paragraph): Tokens.Link | undefined { + if (token.tokens.length !== 1 || token.tokens[0].type !== "link") { + return; + } + const link = token.tokens[0] as Tokens.Link; + return EXTERNAL_URL_REGEX.test(link.href) ? link : undefined; +} + +function avatarMarkup(preview: LinkPreview): string { + if (preview.image) { + return ``; + } + return ``; +} + +function inlineAvatarMarkup(preview: LinkPreview): string { + if (preview.image) { + return ``; + } + return ``; +} + +function renderInlineProfile(href: string, preview: LinkPreview): string { + return `${inlineAvatarMarkup(preview)}${escapeHtml(preview.title)}`; +} + +function renderPreview(href: string, preview: LinkPreview): string { + const safeHref = escapeHtml(href); + if (preview.kind === "profile") { + return `${avatarMarkup(preview)}${escapeHtml(preview.title)}${escapeHtml(preview.site)}`; + } + if (preview.kind === "tweet") { + const body = preview.tweetHtml + ? `
${preview.tweetHtml}
` + : '

View this post on X.

'; + const profileUrl = escapeHtml(preview.profileUrl || href); + return ``; + } + return `${escapeHtml(preview.site)}${escapeHtml(preview.title)}${preview.description ? `${escapeHtml(preview.description)}` : ""}Visit site `; +} + +function agentProfileLink(href: string, preview: LinkPreview): string { + const identity = + preview.title === preview.site + ? preview.title + : `${preview.title} (${preview.site})`; + return `[${identity}](${href})`; +} + +function agentTweetQuote(href: string, preview: LinkPreview): string { + const text = preview.tweetHtml + ? plainText(preview.tweetHtml.replace(BR_TAG_REGEX, "\n")) + : "Post text unavailable. View the original on X."; + const quote = text + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + return `${quote}\n>\n> ${agentProfileLink(href, preview)}`; +} + +function linksInToken(parser: Marked, token: Token): Tokens.Link[] { + const links: Tokens.Link[] = []; + parser.walkTokens([token], (child) => { + if (child.type === "link") { + links.push(child); + } + }); + return links; +} + +function replaceAgentLinks( + raw: string, + links: Tokens.Link[], + previews: Map +): { markdown: string; tweetQuotes: string[] } { + let cursor = 0; + let markdown = ""; + const tweetQuotes: string[] = []; + + for (const link of links) { + const preview = previews.get(link.href); + if (!(preview && ["profile", "tweet"].includes(preview.kind))) { + continue; + } + const index = raw.indexOf(link.raw, cursor); + if (index < 0) { + continue; + } + markdown += raw.slice(cursor, index); + if (preview.kind === "profile") { + markdown += agentProfileLink(link.href, preview); + } else { + markdown += `[X post by ${preview.title} (${preview.site})](${link.href})`; + tweetQuotes.push(agentTweetQuote(link.href, preview)); + } + cursor = index + link.raw.length; + } + + markdown += raw.slice(cursor); + return { markdown, tweetQuotes }; +} + +export async function renderAgentMarkdown(source: string): Promise { + const parser = new Marked({ gfm: true }); + const tokens = parser.lexer(source); + const xLinks: Tokens.Link[] = []; + parser.walkTokens(tokens, (token) => { + if (token.type !== "link" || !EXTERNAL_URL_REGEX.test(token.href)) { + return; + } + try { + if (parseXUrl(new URL(token.href))) { + xLinks.push(token); + } + } catch { + // Malformed links remain unchanged in the Markdown mirror. + } + }); + + const previews = new Map(); + await Promise.all( + xLinks.map(async (link) => { + try { + previews.set(link.href, await cachedPreview(link.href, link.text)); + } catch { + // Preserve the author's original Markdown when metadata is unavailable. + } + }) + ); + + const rendered = tokens.map((token) => { + if (token.type === "code" || token.type === "html") { + return token.raw; + } + if (token.type === "paragraph") { + const standalone = standaloneLink(token); + const preview = standalone ? previews.get(standalone.href) : undefined; + if (standalone && preview?.kind === "tweet") { + return `${agentTweetQuote(standalone.href, preview)}\n\n`; + } + if (standalone && preview?.kind === "profile") { + return `${agentProfileLink(standalone.href, preview)}\n\n`; + } + } + + const { markdown, tweetQuotes } = replaceAgentLinks( + token.raw, + linksInToken(parser, token), + previews + ); + if (tweetQuotes.length === 0) { + return markdown; + } + return `${markdown.trimEnd()}\n\n${tweetQuotes.join("\n\n")}\n\n`; + }); + + return rendered.join("").trimEnd(); +} + +export async function renderRichMarkdown(source: string): Promise { + const parser = new Marked({ gfm: true }); + const tokens = parser.lexer(source); + const standaloneLinks = tokens + .filter((token): token is Tokens.Paragraph => token.type === "paragraph") + .map(standaloneLink) + .filter((link): link is Tokens.Link => Boolean(link)); + const inlineProfileLinks: Tokens.Link[] = []; + parser.walkTokens(tokens, (token) => { + if (token.type !== "link" || !EXTERNAL_URL_REGEX.test(token.href)) { + return; + } + try { + if (parseXUrl(new URL(token.href))?.kind === "profile") { + inlineProfileLinks.push(token); + } + } catch { + // Malformed links retain Marked's normal link rendering. + } + }); + const links = [...standaloneLinks, ...inlineProfileLinks].filter( + (link, index, allLinks) => + allLinks.findIndex((candidate) => candidate.href === link.href) === index + ); + const previews = new Map(); + + await Promise.all( + links.map(async (link) => { + try { + previews.set(link.href, await cachedPreview(link.href, link.text)); + } catch { + // The normal Markdown link remains the fallback for malformed URLs. + } + }) + ); + + parser.use({ + renderer: { + link({ href, title, tokens: linkTokens }) { + const preview = previews.get(href); + if (preview?.kind === "profile") { + return renderInlineProfile(href, preview); + } + const text = this.parser.parseInline(linkTokens); + const titleAttribute = title ? ` title="${escapeHtml(title)}"` : ""; + const external = EXTERNAL_URL_REGEX.test(href); + return `${text}`; + }, + paragraph(token) { + const link = standaloneLink(token); + const preview = link ? previews.get(link.href) : undefined; + if (link && preview) { + return `${renderPreview(link.href, preview)}\n`; + } + return `

${this.parser.parseInline(token.tokens)}

\n`; + }, + }, + }); + + return parser.parser(tokens) as string; +} diff --git a/lib/seo.ts b/lib/seo.ts index 15eb158..d3bd228 100644 --- a/lib/seo.ts +++ b/lib/seo.ts @@ -1,6 +1,6 @@ import { personConfig } from "@/lib/site-config"; -export type SeoKey = "home" | "about" | "projects" | "connect"; +export type SeoKey = "home" | "about" | "projects" | "blog" | "connect"; /** * Single source of truth for per-page (≤60 chars) and meta description @@ -22,6 +22,11 @@ export const pageSeo: Record<SeoKey, { title: string; description: string }> = { description: "Open-source tools for consent, privacy, and developer experience by Kaylee Williams at Inth: c15t, Cookiebench, DSAR, and Leadtype.", }, + blog: { + title: "Blog | Kaylee Williams", + description: + "Notes from Kaylee Williams on open-source compliance, developer tooling, privacy, and the details that make software feel finished.", + }, connect: { title: "Connect — Kaylee Williams", description: diff --git a/package.json b/package.json index 14e2dce..69b9558 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "build:node": "ASTRO_ADAPTER=node ASTRO_TELEMETRY_DISABLED=1 astro build", "start": "bun run build:node && HOST=127.0.0.1 PORT=4321 node dist/server/entry.mjs", "curate": "bun run scripts/curate.ts", - "fmt": "biome check --write" + "fmt": "biome check --write", + "test": "bun test" }, "dependencies": { "@astrojs/svelte": "^9.0.0", diff --git a/public/AGENTS.md b/public/AGENTS.md index 438bb58..2cee763 100644 --- a/public/AGENTS.md +++ b/public/AGENTS.md @@ -9,7 +9,7 @@ stack: consent, data rights, performance, and agent-readable docs, in code. - `/llms.txt` — concise index (llmstxt.org format) - `/llms-full.txt` — the full profile inlined, including live OSS activity - `/sitemap.md` — Markdown sitemap; `/sitemap.xml` — XML sitemap -- Every page has a Markdown mirror: `/index.md`, `/about.md`, `/projects.md`, `/connect.md` +- Every page has a Markdown mirror: `/index.md`, `/about.md`, `/projects.md`, `/blog.md`, `/connect.md`, and `/blog/{slug}.md` - Pages also answer `Accept: text/markdown` with their Markdown mirror ## Usage diff --git a/src/components/Nav.astro b/src/components/Nav.astro index 1e991d5..cc67712 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -5,6 +5,7 @@ const links = [ { href: "/", label: "Home" }, { href: "/about", label: "About" }, { href: "/projects", label: "Projects" }, + { href: "/blog", label: "Blog" }, { href: "/connect", label: "Connect" }, ]; diff --git a/src/content.config.ts b/src/content.config.ts index ef98807..b8a3a4f 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -48,4 +48,16 @@ const experience = defineCollection({ }), }); -export const collections = { experience, projects, site }; +const blog = defineCollection({ + loader: glob({ base: "./content/blog", pattern: "**/*.{md,mdx}" }), + schema: z.object({ + title: z.string(), + description: z.string(), + publishedAt: z.coerce.date(), + updatedAt: z.coerce.date().optional(), + tags: z.array(z.string()).default([]), + draft: z.boolean().default(false), + }), +}); + +export const collections = { blog, experience, projects, site }; diff --git a/src/pages/blog.md.ts b/src/pages/blog.md.ts new file mode 100644 index 0000000..43d2aa3 --- /dev/null +++ b/src/pages/blog.md.ts @@ -0,0 +1,32 @@ +import { markdownResponse } from "@/lib/agent-markdown"; +import { getBlogPosts } from "@/lib/get-blog-posts"; +import { pageSeo } from "@/lib/seo"; + +export const prerender = true; + +export async function GET({ url }: { url: URL }): Promise<Response> { + const posts = await getBlogPosts(); + const body = [ + "---", + `title: "${pageSeo.blog.title}"`, + `description: "${pageSeo.blog.description}"`, + `canonical_url: "${url.origin}/blog"`, + "---", + "", + "# Blog", + "", + ...posts.flatMap((post) => [ + `## [${post.data.title}](${url.origin}/blog/${post.id}.md)`, + "", + post.data.description, + "", + `Published: ${post.data.publishedAt.toISOString().slice(0, 10)}`, + "", + ]), + ].join("\n"); + + return markdownResponse(`${body.trimEnd()}\n`, { + base: url.origin, + canonicalPath: "/blog", + }); +} diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro new file mode 100644 index 0000000..bdfcf62 --- /dev/null +++ b/src/pages/blog/[slug].astro @@ -0,0 +1,67 @@ +--- +import BaseLayout from "@/src/layouts/BaseLayout.astro"; +import { getBlogPosts, type BlogPost } from "@/lib/get-blog-posts"; +import { renderRichMarkdown } from "@/lib/rich-markdown"; + +export const prerender = true; + +export async function getStaticPaths() { + const posts = await getBlogPosts(); + return posts.map((post) => ({ params: { slug: post.id }, props: { post } })); +} + +type Props = { post: BlogPost }; + +const { post } = Astro.props; +const html = await renderRichMarkdown(post.body ?? ""); +const dateFormat = new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "long", + year: "numeric", +}); +--- + +<BaseLayout + title={`${post.data.title} | Kaylee Williams`} + description={post.data.description} +> + <article> + <a + class="mb-7 inline-flex items-center gap-1.5 rounded-md text-muted-foreground text-sm transition-colors hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary" + href="/blog" + > + <span aria-hidden="true">←</span> + All posts + </a> + + <header class="mb-9 space-y-4 border-b border-border pb-7"> + <h1 class="font-bold text-3xl tracking-tight sm:text-4xl">{post.data.title}</h1> + <p class="max-w-prose text-lg text-muted-foreground"> + {post.data.description} + </p> + <div class="flex flex-wrap items-center gap-x-3 gap-y-2 text-muted-foreground text-sm"> + <time datetime={post.data.publishedAt.toISOString()}> + {dateFormat.format(post.data.publishedAt)} + </time> + { + post.data.updatedAt ? ( + <> + <span aria-hidden="true">·</span> + <span>Updated {dateFormat.format(post.data.updatedAt)}</span> + </> + ) : null + } + { + post.data.tags.length > 0 ? ( + <> + <span aria-hidden="true">·</span> + <span>{post.data.tags.join(" · ")}</span> + </> + ) : null + } + </div> + </header> + + <div class="blog-prose" set:html={html} /> + </article> +</BaseLayout> diff --git a/src/pages/blog/[slug].md.ts b/src/pages/blog/[slug].md.ts new file mode 100644 index 0000000..fe2d392 --- /dev/null +++ b/src/pages/blog/[slug].md.ts @@ -0,0 +1,42 @@ +import { markdownResponse } from "@/lib/agent-markdown"; +import { type BlogPost, getBlogPosts } from "@/lib/get-blog-posts"; +import { renderAgentMarkdown } from "@/lib/rich-markdown"; + +export const prerender = true; + +export async function getStaticPaths() { + const posts = await getBlogPosts(); + return posts.map((post) => ({ params: { slug: post.id }, props: { post } })); +} + +export async function GET({ + props, + url, +}: { + props: { post: BlogPost }; + url: URL; +}): Promise<Response> { + const { post } = props; + const content = await renderAgentMarkdown(post.body ?? ""); + const body = [ + "---", + `title: ${JSON.stringify(post.data.title)}`, + `description: ${JSON.stringify(post.data.description)}`, + `canonical_url: "${url.origin}/blog/${post.id}"`, + `published: "${post.data.publishedAt.toISOString()}"`, + ...(post.data.updatedAt + ? [`updated: "${post.data.updatedAt.toISOString()}"`] + : []), + "---", + "", + `# ${post.data.title}`, + "", + content, + "", + ].join("\n"); + + return markdownResponse(body, { + base: url.origin, + canonicalPath: `/blog/${post.id}`, + }); +} diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro new file mode 100644 index 0000000..ba5dcbf --- /dev/null +++ b/src/pages/blog/index.astro @@ -0,0 +1,69 @@ +--- +import BaseLayout from "@/src/layouts/BaseLayout.astro"; +import { getBlogPosts } from "@/lib/get-blog-posts"; +import { pageSeo } from "@/lib/seo"; + +export const prerender = true; + +const posts = await getBlogPosts(); +const dateFormat = new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", + year: "numeric", +}); +--- + +<BaseLayout title={pageSeo.blog.title} description={pageSeo.blog.description}> + <header class="space-y-2"> + <h2 class="font-bold text-3xl tracking-tight">Blog</h2> + <p class="max-w-prose text-muted-foreground"> + Notes on compliance, developer tools, and the small decisions that make software + hold together. + </p> + </header> + + { + posts.length > 0 ? ( + <ol class="divide-y divide-border" aria-label="Blog posts"> + {posts.map((post) => ( + <li class="py-5 first:pt-0"> + <article class="flex flex-col gap-2"> + <div class="flex items-baseline justify-between gap-4"> + <a + class="group font-semibold text-lg text-primary" + href={`/blog/${post.id}`} + > + <span class="group-hover:underline">{post.data.title}</span> + </a> + <time + class="shrink-0 text-muted-foreground text-sm tabular-nums" + datetime={post.data.publishedAt.toISOString()} + > + {dateFormat.format(post.data.publishedAt)} + </time> + </div> + <p class="max-w-prose text-muted-foreground text-sm"> + {post.data.description} + </p> + {post.data.tags.length > 0 ? ( + <ul + class="flex flex-wrap gap-x-3 gap-y-1 text-muted-foreground text-xs" + aria-label="Topics" + > + {post.data.tags.map((tag) => ( + <li>{tag}</li> + ))} + </ul> + ) : null} + </article> + </li> + ))} + </ol> + ) : ( + <div class="rounded-lg border border-dashed border-border px-6 py-10 text-center"> + <p class="font-semibold">Nothing published yet.</p> + <p class="mt-1 text-muted-foreground text-sm">The drafts are behaving themselves.</p> + </div> + ) + } +</BaseLayout> diff --git a/src/pages/sitemap.md.ts b/src/pages/sitemap.md.ts index 48225ba..af001c4 100644 --- a/src/pages/sitemap.md.ts +++ b/src/pages/sitemap.md.ts @@ -3,6 +3,6 @@ import { markdownResponse, renderSitemapMarkdown } from "@/lib/agent-markdown"; export const prerender = true; -export function GET({ url }: APIContext): Response { - return markdownResponse(renderSitemapMarkdown(url.origin)); +export async function GET({ url }: APIContext): Promise<Response> { + return markdownResponse(await renderSitemapMarkdown(url.origin)); } diff --git a/src/pages/sitemap.xml.ts b/src/pages/sitemap.xml.ts index 3ea5c0d..26293d2 100644 --- a/src/pages/sitemap.xml.ts +++ b/src/pages/sitemap.xml.ts @@ -1,16 +1,20 @@ import type { APIContext } from "astro"; +import { getBlogPosts } from "@/lib/get-blog-posts"; export const prerender = true; const TRAILING_SLASHES_REGEX = /\/+$/; -export function GET({ url }: APIContext): Response { +export async function GET({ url }: APIContext): Promise<Response> { const base = url.origin.replace(TRAILING_SLASHES_REGEX, ""); const now = new Date().toISOString(); + const posts = await getBlogPosts(); const routes = [ { path: "/", priority: "1" }, { path: "/about", priority: "0.8" }, { path: "/projects", priority: "0.8" }, + { path: "/blog", priority: "0.7" }, + ...posts.map((post) => ({ path: `/blog/${post.id}`, priority: "0.6" })), { path: "/connect", priority: "0.5" }, { path: "/records", priority: "0.4" }, ]; diff --git a/src/styles/globals.css b/src/styles/globals.css index a1064a1..f3c5934 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -258,6 +258,337 @@ h3 { text-decoration: underline; } +.blog-prose { + max-width: 70ch; + font-size: 1.0625rem; + line-height: 1.75; +} + +.blog-prose > * + * { + margin-top: 1.35em; +} + +.blog-prose h2, +.blog-prose h3 { + line-height: 1.2; + letter-spacing: -0.025em; +} + +.blog-prose h2 { + margin-top: 2.4em; + font-size: 1.65rem; + font-weight: 750; +} + +.blog-prose h3 { + margin-top: 2em; + font-size: 1.3rem; + font-weight: 700; +} + +.blog-prose :is(ul, ol) { + padding-left: 1.4em; +} + +.blog-prose ul { + list-style: disc; +} + +.blog-prose ol { + list-style: decimal; +} + +.blog-prose li + li { + margin-top: 0.4em; +} + +.blog-prose :not(.rich-profile, .rich-link, .rich-tweet) a { + color: var(--primary); + text-decoration-line: underline; + text-decoration-thickness: 0.08em; + text-underline-offset: 0.2em; +} + +.blog-prose :not(.rich-profile, .rich-link, .rich-tweet) a:hover { + text-decoration-thickness: 0.13em; +} + +.blog-prose a.inline-profile { + display: inline-flex; + gap: 0.32em; + align-items: center; + max-width: 100%; + padding: 0.08em 0.48em 0.08em 0.12em; + margin-inline: 0.08em; + font-size: 0.9em; + line-height: 1.4; + vertical-align: -0.18em; + color: var(--primary); + white-space: nowrap; + text-decoration: none; + background: color-mix(in srgb, var(--primary) 8%, var(--card)); + border: 1px solid color-mix(in srgb, var(--primary) 24%, var(--border)); + border-radius: 9999px; +} + +.blog-prose .inline-profile:hover { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 55%, var(--border)); +} + +.blog-prose .inline-profile:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.inline-profile__avatar, +.inline-profile > span { + flex: none; + width: 1.25rem; + height: 1.25rem; + object-fit: cover; + border-radius: 9999px; +} + +.inline-profile > span { + display: inline-grid; + place-items: center; + font-size: 0.7em; + font-weight: 800; + color: var(--primary); + background: color-mix(in srgb, var(--primary) 14%, var(--muted)); +} + +.inline-profile strong { + overflow: hidden; + text-overflow: ellipsis; +} + +.blog-prose blockquote { + padding: 1.1rem 1.25rem; + color: var(--muted-foreground); + background: color-mix(in srgb, var(--muted) 65%, transparent); + border: 1px solid var(--border); + border-radius: 0.75rem; +} + +.blog-prose :is(code, pre) { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.blog-prose :not(pre) > code { + padding: 0.14em 0.35em; + font-size: 0.9em; + background: var(--muted); + border-radius: 0.3rem; +} + +.blog-prose pre { + padding: 1rem 1.15rem; + overflow-x: auto; + font-size: 0.9rem; + line-height: 1.65; + background: var(--muted); + border: 1px solid var(--border); + border-radius: 0.75rem; +} + +.blog-prose img:not(.rich-embed__avatar, .inline-profile__avatar) { + width: 100%; + border-radius: 0.75rem; +} + +.rich-profile, +.rich-link, +.rich-tweet { + display: block; + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.8rem; + box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 6%, transparent); +} + +.rich-profile, +.rich-link { + color: var(--card-foreground); + text-decoration: none; + transition: + border-color 160ms ease-out, + transform 160ms ease-out, + box-shadow 160ms ease-out; +} + +.rich-profile:hover, +.rich-link:hover { + border-color: color-mix(in srgb, var(--primary) 48%, var(--border)); + box-shadow: 0 8px 24px color-mix(in srgb, var(--foreground) 8%, transparent); + transform: translateY(-2px); +} + +.rich-profile:focus-visible, +.rich-link:focus-visible, +.rich-tweet a:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 3px; +} + +.rich-profile { + display: flex; + gap: 0.8rem; + align-items: center; + padding: 0.85rem 1rem; +} + +.rich-profile > span:nth-child(2), +.rich-tweet__author > span { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + line-height: 1.3; +} + +.rich-profile strong, +.rich-tweet strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rich-profile small, +.rich-tweet small { + font-size: 0.78rem; + color: var(--muted-foreground); +} + +.rich-embed__avatar { + flex: none; + width: 2.75rem; + height: 2.75rem; + object-fit: cover; + border-radius: 9999px; +} + +.rich-embed__avatar--fallback { + display: grid; + place-items: center; + font-weight: 800; + color: var(--primary); + background: color-mix(in srgb, var(--primary) 14%, var(--muted)); +} + +.rich-embed__mark { + font-size: 1.15rem; + color: var(--muted-foreground); +} + +.rich-tweet { + padding: 1rem 1.1rem; +} + +.rich-tweet header { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.rich-tweet__author { + display: flex; + flex: 1; + gap: 0.75rem; + align-items: center; + min-width: 0; + color: var(--foreground); + text-decoration: none; +} + +.blog-prose a.rich-tweet__author, +.blog-prose a.rich-tweet__author:hover { + text-decoration: none; +} + +.rich-tweet__author:hover strong { + color: var(--primary); +} + +.rich-tweet__body, +.rich-tweet__fallback { + margin-top: 1rem; + font-size: 1rem; + line-height: 1.65; +} + +.rich-tweet__body a { + color: var(--primary); +} + +.rich-tweet__source { + display: inline-flex; + gap: 0.25rem; + align-items: center; + margin-top: 0.8rem; + font-size: 0.8rem; + font-weight: 700; + color: var(--muted-foreground); + text-decoration: none; +} + +.rich-tweet__source:hover { + color: var(--primary); +} + +.rich-link { + padding: 1rem 1.1rem 1.05rem; +} + +.rich-link > span, +.rich-link > strong { + display: block; +} + +.rich-link__site { + margin-bottom: 0.3rem; + font-size: 0.75rem; + font-weight: 750; + color: var(--primary); + letter-spacing: 0.04em; +} + +.rich-link > strong { + font-size: 1.05rem; + line-height: 1.35; +} + +.rich-link__description { + display: -webkit-box !important; + -webkit-box-orient: vertical; + margin-top: 0.35rem; + overflow: hidden; + -webkit-line-clamp: 2; + font-size: 0.88rem; + line-height: 1.5; + color: var(--muted-foreground); +} + +.rich-link__action { + margin-top: 0.7rem; + font-size: 0.78rem; + font-weight: 700; + color: var(--muted-foreground); +} + +@media (prefers-reduced-motion: reduce) { + .rich-profile, + .rich-link { + transition: none; + } + + .rich-profile:hover, + .rich-link:hover { + transform: none; + } +} + /* Staggered entrance for the /connect surfaces (delay set inline per element). */ @keyframes rise { from {