mirror of
https://github.com/prdlk/prdlk.git
synced 2026-11-02 13:12:49 +00:00
feat(components): add reusable Astro components and documentation
This commit is contained in:
1 parent
9cee393b06
commit
a0e21c1b78
25 files changed
+1059
No files matched your search
@@ -0,0 +1,43 @@
|
||||
<!-- Parent: ../AGENTS.md -->
|
||||
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||
|
||||
# components
|
||||
|
||||
## Purpose
|
||||
Reusable `.astro` components for layout, content rendering, theming, search, and SEO.
|
||||
|
||||
## Key Files
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `BaseHead.astro` | All `<head>` tags: title, meta, canonical, Open Graph/Twitter, favicons, RSS auto-discovery, webmentions. |
|
||||
| `ExperienceTags.astro` | Renders linked chips for an entry's `experiences` refs; resolves them with `getEntries` and links to `/experience/<id>`. Used by Masthead, Project, and the speaking page. |
|
||||
| `YouTube.astro` | Responsive YouTube embed (`youtube-nocookie.com/embed/<id>`). Used on speaking detail pages. No external player dependency. |
|
||||
| `FormattedDate.astro` | `<time>` element formatted per `siteConfig.date`. |
|
||||
| `Paginator.astro` | Prev/next pagination links. |
|
||||
| `Search.astro` | Pagefind search UI (client-side, works only after build). |
|
||||
| `ThemeProvider.astro` / `ThemeToggle.astro` | Dark/light mode state + toggle button. |
|
||||
| `SkipLink.astro` | Accessibility skip-to-content link. |
|
||||
|
||||
## Subdirectories
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `blog/` | Writing/post rendering: masthead, preview, table of contents, webmentions (see `blog/AGENTS.md`). |
|
||||
| `layout/` | Site chrome: header + footer (see `layout/AGENTS.md`). |
|
||||
| `project/` | Project rendering (see `project/AGENTS.md`). |
|
||||
|
||||
## For AI Agents
|
||||
|
||||
### Working In This Directory
|
||||
- Components consume Content Collection types like `CollectionEntry<"writing">`; keep prop types in sync with `src/content.config.ts`.
|
||||
- A CSS `filter` (e.g. `grayscale`) on an element makes it a containing block for absolutely-positioned children — this matters for the header logo (see `layout/AGENTS.md`).
|
||||
|
||||
### Common Patterns
|
||||
- Polymorphic `as` prop (via `astro/types` `Polymorphic`) for heading-level control (e.g. `PostPreview`, `Project`).
|
||||
- `data-pagefind-body` marks content for the search index.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### External
|
||||
- `astro-icon` for SVG icons — `lucide` for UI icons, `simple-icons` for brand/social logos (`@iconify-json/*` sets). Programming-language logos should use `devicon` (not yet installed — no consumer yet).
|
||||
|
||||
<!-- MANUAL: -->
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
import { WEBMENTION_PINGBACK, WEBMENTION_URL } from "astro:env/client";
|
||||
import { siteConfig, socialLinks } from "@/site.config";
|
||||
import type { SiteMeta } from "@/types";
|
||||
import "@/styles/global.css";
|
||||
|
||||
type Props = SiteMeta;
|
||||
|
||||
const { articleDate, description, keywords, ogImage, schema, title } = Astro.props;
|
||||
|
||||
const titleSeparator = "•";
|
||||
const siteTitle = `${title} ${titleSeparator} ${siteConfig.title}`;
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
const socialImageURL = new URL(ogImage ? ogImage : "/social-card.png", Astro.url).href;
|
||||
|
||||
const xLink = socialLinks.find((s) => s.friendlyName === "X")?.link;
|
||||
const twitterHandle = xLink ? `@${xLink.split("/").pop()}` : undefined;
|
||||
const schemas = schema ? (Array.isArray(schema) ? schema : [schema]) : [];
|
||||
---
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>{siteTitle}</title>
|
||||
|
||||
{/* Icons */}
|
||||
<link href="/icon.svg" rel="icon" type="image/svg+xml" />
|
||||
{
|
||||
import.meta.env.PROD && (
|
||||
<>
|
||||
{/* Favicon & Apple Icon, Generated at build */}
|
||||
<link rel="icon" href="/favicon-32x32.png" type="image/png" />
|
||||
<link href="/icons/apple-touch-icon.png" rel="apple-touch-icon" />
|
||||
{/* Manifest */}
|
||||
<link href="/manifest.webmanifest" rel="manifest" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Canonical URL */}
|
||||
<link href={canonicalURL} rel="canonical" />
|
||||
|
||||
{/* Primary Meta Tags */}
|
||||
<meta content={siteTitle} name="title" />
|
||||
<meta content={description} name="description" />
|
||||
{keywords && keywords.length > 0 && <meta content={keywords.join(", ")} name="keywords" />}
|
||||
<meta content={siteConfig.author} name="author" />
|
||||
|
||||
{/* Tell crawlers to index and allow full image/snippet/video previews */}
|
||||
<meta
|
||||
content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
|
||||
name="robots"
|
||||
/>
|
||||
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta content={articleDate ? "article" : "website"} property="og:type" />
|
||||
<meta content={title} property="og:title" />
|
||||
<meta content={description} property="og:description" />
|
||||
<meta content={canonicalURL} property="og:url" />
|
||||
<meta content={siteConfig.title} property="og:site_name" />
|
||||
<meta content={siteConfig.ogLocale} property="og:locale" />
|
||||
<meta content={socialImageURL} property="og:image" />
|
||||
<meta content={`${title} — ${siteConfig.title}`} property="og:image:alt" />
|
||||
<meta content="1200" property="og:image:width" />
|
||||
<meta content="630" property="og:image:height" />
|
||||
{
|
||||
articleDate && (
|
||||
<>
|
||||
<meta content={siteConfig.author} property="article:author" />
|
||||
<meta content={articleDate} property="article:published_time" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta content="summary_large_image" property="twitter:card" />
|
||||
<meta content={canonicalURL} property="twitter:url" />
|
||||
<meta content={title} property="twitter:title" />
|
||||
<meta content={description} property="twitter:description" />
|
||||
<meta content={socialImageURL} property="twitter:image" />
|
||||
{twitterHandle && <meta content={twitterHandle} property="twitter:creator" />}
|
||||
{twitterHandle && <meta content={twitterHandle} property="twitter:site" />}
|
||||
|
||||
{/* Structured data (schema.org JSON-LD) */}
|
||||
{schemas.map((s) => <script type="application/ld+json" is:inline set:html={JSON.stringify(s)} />)}
|
||||
|
||||
{/* Sitemap */}
|
||||
<link href="/sitemap-index.xml" rel="sitemap" />
|
||||
|
||||
{/* RSS auto-discovery */}
|
||||
<link href="/rss.xml" title="Writing" rel="alternate" type="application/rss+xml" />
|
||||
<link href="/projects/rss.xml" title="Projects" rel="alternate" type="application/rss+xml" />
|
||||
|
||||
{/* Webmentions */}
|
||||
{
|
||||
WEBMENTION_URL && (
|
||||
<>
|
||||
<link href={WEBMENTION_URL} rel="webmention" />
|
||||
{WEBMENTION_PINGBACK && <link href={WEBMENTION_PINGBACK} rel="pingback" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<meta content={Astro.generator} name="generator" />
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import { getEntries } from "astro:content";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
|
||||
interface Props {
|
||||
experiences: CollectionEntry<"projects" | "writing" | "speaking">["data"]["experiences"];
|
||||
}
|
||||
|
||||
const { experiences } = Astro.props;
|
||||
const entries = experiences.length ? await getEntries(experiences) : [];
|
||||
---
|
||||
|
||||
{
|
||||
entries.length > 0 && (
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
{entries.map((exp) => (
|
||||
<li>
|
||||
<a class="cactus-link inline-block" href={`/experience/${exp.id}/`}>
|
||||
{exp.data.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
import { getFormattedDate } from "@/utils/date";
|
||||
|
||||
type Props = HTMLAttributes<"time"> & {
|
||||
date: Date;
|
||||
dateTimeOptions?: Intl.DateTimeFormatOptions;
|
||||
};
|
||||
|
||||
const { date, dateTimeOptions, ...attrs } = Astro.props;
|
||||
|
||||
const postDate = getFormattedDate(date, dateTimeOptions);
|
||||
const ISO = date.toISOString();
|
||||
---
|
||||
|
||||
<time datetime={ISO} title={ISO} {...attrs}>{postDate}</time>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
import type { PaginationLink } from "@/types";
|
||||
|
||||
interface Props {
|
||||
nextUrl?: PaginationLink;
|
||||
prevUrl?: PaginationLink;
|
||||
}
|
||||
|
||||
const { nextUrl, prevUrl } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
(prevUrl || nextUrl) && (
|
||||
<nav class="mt-8 flex items-center gap-x-4">
|
||||
{prevUrl && (
|
||||
<a class="hover:text-accent me-auto py-2" href={prevUrl.url}>
|
||||
{prevUrl.srLabel && <span class="sr-only">{prevUrl.srLabel}</span>}
|
||||
{prevUrl.text ? prevUrl.text : "Previous"}
|
||||
</a>
|
||||
)}
|
||||
{nextUrl && (
|
||||
<a class="hover:text-accent ms-auto py-2" href={nextUrl.url}>
|
||||
{nextUrl.srLabel && <span class="sr-only">{nextUrl.srLabel}</span>}
|
||||
{nextUrl.text ? nextUrl.text : "Next"}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import "@/styles/blocks/search.css";
|
||||
---
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
if (import.meta.env.DEV) return;
|
||||
const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1));
|
||||
onIdle(async () => {
|
||||
await import("@pagefind/component-ui");
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<pagefind-config
|
||||
baseUrl={`${import.meta.env.BASE_URL}`}
|
||||
bundle-path={`${import.meta.env.BASE_URL.replace(/\/$/, "")}/pagefind/`}></pagefind-config>
|
||||
<pagefind-modal-trigger id="search" class="ms-auto" hide-shortcut compact></pagefind-modal-trigger>
|
||||
<pagefind-modal></pagefind-modal>
|
||||
@@ -0,0 +1,3 @@
|
||||
<a class="sr-only focus:not-sr-only focus:fixed focus:start-1 focus:top-1.5" href="#main"
|
||||
>skip to content
|
||||
</a>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import { slugifyTag } from "@/data/tags";
|
||||
|
||||
interface Props {
|
||||
tags: string[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { tags, class: className } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
tags.length > 0 && (
|
||||
<ul class:list={["flex flex-wrap gap-x-3 gap-y-2", className]}>
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a
|
||||
class="text-muted hover:text-link text-xs before:content-['#']"
|
||||
href={`/skills/${slugifyTag(tag)}/`}
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{/* Inlined to avoid FOUC. This is a parser blocking script. */}
|
||||
<script is:inline>
|
||||
const lightModePref = window.matchMedia("(prefers-color-scheme: light)");
|
||||
|
||||
function getUserPref() {
|
||||
const storedTheme = typeof localStorage !== "undefined" && localStorage.getItem("theme");
|
||||
return storedTheme || (lightModePref.matches ? "light" : "dark");
|
||||
}
|
||||
|
||||
function setTheme(newTheme) {
|
||||
if (newTheme !== "light" && newTheme !== "dark") {
|
||||
return console.warn(
|
||||
`Invalid theme value '${newTheme}' received. Expected 'light' or 'dark'.`,
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// root already set to newTheme, exit early
|
||||
if (newTheme === root.getAttribute("data-theme")) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.setAttribute("data-theme", newTheme);
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem("theme", newTheme);
|
||||
}
|
||||
}
|
||||
|
||||
// initial setup
|
||||
setTheme(getUserPref());
|
||||
|
||||
// listen for pageshow event, browser restored page from bfcache
|
||||
window.addEventListener("pageshow", (e) => {
|
||||
if (e.persisted) {
|
||||
setTheme(getUserPref());
|
||||
}
|
||||
});
|
||||
|
||||
// listen for theme-change custom event, fired in src/components/ThemeToggle.astro
|
||||
document.addEventListener("theme-change", (e) => {
|
||||
setTheme(e.detail.theme);
|
||||
});
|
||||
|
||||
// listen for prefers-color-scheme change.
|
||||
lightModePref.addEventListener("change", (e) => setTheme(e.matches ? "light" : "dark"));
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<theme-toggle class="ms-2 sm:ms-4">
|
||||
<button class="hover:text-accent relative h-9 w-9 cursor-pointer p-2" type="button">
|
||||
<span class="sr-only">Dark Theme</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute inset-s-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="sun-svg"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"></path>
|
||||
<path d="M22 12L23 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M12 2V1" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M12 23V22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M20 20L19 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M20 4L19 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M4 20L5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M4 4L5 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
<path d="M1 12L2 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute inset-s-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="moon-svg"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" stroke="none"></path>
|
||||
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z"
|
||||
></path>
|
||||
<path d="M17 4a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2"></path>
|
||||
<path d="M19 11h2m-1 -1v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</theme-toggle>
|
||||
|
||||
<script>
|
||||
// Note that if you fire the theme-change event outside of this component, it will not be reflected in the button's aria-checked attribute. You will need to add an event listener if you want that.
|
||||
import { rootInDarkMode } from "@/utils/domElement";
|
||||
|
||||
class ThemeToggle extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const button = this.querySelector<HTMLButtonElement>("button");
|
||||
|
||||
if (button) {
|
||||
// set aria role value
|
||||
button.setAttribute("role", "switch");
|
||||
button.setAttribute("aria-checked", String(rootInDarkMode()));
|
||||
|
||||
// button event
|
||||
button.addEventListener("click", () => {
|
||||
// invert theme
|
||||
let themeChangeEvent = new CustomEvent("theme-change", {
|
||||
detail: {
|
||||
theme: rootInDarkMode() ? "light" : "dark",
|
||||
},
|
||||
});
|
||||
// dispatch event -> ThemeProvider.astro
|
||||
document.dispatchEvent(themeChangeEvent);
|
||||
|
||||
// set the aria-checked attribute
|
||||
button.setAttribute("aria-checked", String(rootInDarkMode()));
|
||||
});
|
||||
} else {
|
||||
console.warn("Theme Toggle: No button found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("theme-toggle", ThemeToggle);
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
interface Props {
|
||||
id: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const { id, title = "YouTube video player" } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="aspect-video overflow-hidden rounded-md">
|
||||
<iframe
|
||||
class="h-full w-full border-0"
|
||||
src={`https://www.youtube-nocookie.com/embed/${id}`}
|
||||
title={title}
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen></iframe>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!-- Parent: ../AGENTS.md -->
|
||||
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||
|
||||
# blog
|
||||
|
||||
## Purpose
|
||||
Components for rendering Writing (the `writing` collection) — post mastheads, list previews, table of contents, and webmentions. (Folder is named `blog` for historical reasons; it serves the `/writing` section.)
|
||||
|
||||
## Key Files
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `Masthead.astro` | Post header: cover image, title, date, reading time, updated badge, and `ExperienceTags` (linked experience chips — replaces the old tag links). |
|
||||
| `PostPreview.astro` | One-line writing entry (date + linked title) used in lists; links to `/writing/<id>`. |
|
||||
| `TOC.astro` / `TOCHeading.astro` | Table of contents built from rendered headings. |
|
||||
|
||||
## Subdirectories
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `webmentions/` | Webmention display components (see `webmentions/AGENTS.md`). |
|
||||
|
||||
## For AI Agents
|
||||
|
||||
### Working In This Directory
|
||||
- These consume `CollectionEntry<"writing">`.
|
||||
- `Masthead` renders `data.experiences` via `ExperienceTags`; it no longer links freeform `tags` to a `/tags` route (that route was removed).
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal
|
||||
- `@/components/ExperienceTags.astro`, `@/components/FormattedDate.astro`, `@/utils/generateToc`.
|
||||
|
||||
<!-- MANUAL: -->
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import ExperienceTags from "@/components/ExperienceTags.astro";
|
||||
import FormattedDate from "@/components/FormattedDate.astro";
|
||||
import TagList from "@/components/TagList.astro";
|
||||
|
||||
interface Props {
|
||||
content: CollectionEntry<"writing">;
|
||||
readingTime: string;
|
||||
}
|
||||
|
||||
const {
|
||||
content: { data },
|
||||
readingTime,
|
||||
} = Astro.props;
|
||||
|
||||
const dateTimeOptions: Intl.DateTimeFormatOptions = {
|
||||
month: "long",
|
||||
};
|
||||
---
|
||||
|
||||
{
|
||||
data.coverImage && (
|
||||
<div class="mb-6 aspect-video">
|
||||
<Image
|
||||
alt={data.coverImage.alt}
|
||||
layout="constrained"
|
||||
width={748}
|
||||
height={420}
|
||||
priority
|
||||
src={data.coverImage.src}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{data.draft ? <span class="text-base text-red-500">(Draft)</span> : null}
|
||||
<h1 class="title">
|
||||
{data.title}
|
||||
</h1>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<p class="font-semibold">
|
||||
<FormattedDate date={data.publishDate} dateTimeOptions={dateTimeOptions} /> /{" "}
|
||||
{readingTime}
|
||||
</p>
|
||||
{
|
||||
data.updatedDate && (
|
||||
<span class="bg-quote/5 text-quote rounded-lg px-2 py-1">
|
||||
Updated:
|
||||
<FormattedDate class="ms-1" date={data.updatedDate} dateTimeOptions={dateTimeOptions} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!!data.experiences?.length && (
|
||||
<div class="mt-3">
|
||||
<ExperienceTags experiences={data.experiences} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<TagList tags={data.tags} class="mt-3" />
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import type { HTMLTag, Polymorphic } from "astro/types";
|
||||
import FormattedDate from "@/components/FormattedDate.astro";
|
||||
|
||||
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
|
||||
post: CollectionEntry<"writing">;
|
||||
withDesc?: boolean;
|
||||
};
|
||||
|
||||
const { as: Tag = "div", post, withDesc = false } = Astro.props;
|
||||
const { externalUrl, publisher } = post.data;
|
||||
const href = externalUrl ?? `/writing/${post.id}/`;
|
||||
---
|
||||
|
||||
<FormattedDate class="text-muted min-w-30 font-semibold" date={post.data.publishDate} />
|
||||
<Tag>
|
||||
{post.data.draft && <span class="text-red-500">(Draft) </span>}
|
||||
<a
|
||||
class="cactus-link"
|
||||
href={href}
|
||||
{...externalUrl ? { rel: "noopener noreferrer", target: "_blank" } : {}}
|
||||
>
|
||||
{post.data.title}
|
||||
</a>
|
||||
{
|
||||
externalUrl && (
|
||||
<span class="text-muted text-xs whitespace-nowrap">
|
||||
{" ↗ "}
|
||||
{publisher ?? "External"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
{withDesc && <q class="line-clamp-3 italic">{post.data.description}</q>}
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import type { MarkdownHeading } from "astro";
|
||||
import { generateToc } from "@/utils/generateToc";
|
||||
import TOCHeading from "./TOCHeading.astro";
|
||||
|
||||
interface Props {
|
||||
headings: MarkdownHeading[];
|
||||
}
|
||||
|
||||
const { headings } = Astro.props;
|
||||
|
||||
const toc = generateToc(headings);
|
||||
---
|
||||
|
||||
<details open class="lg:sticky lg:top-12 lg:order-2 lg:-me-32 lg:basis-64">
|
||||
<summary class="title hover:marker:text-accent cursor-pointer text-lg">Table of Contents</summary>
|
||||
<nav class="ms-4 lg:w-full">
|
||||
<ol class="mt-4">
|
||||
{toc.map((heading) => <TOCHeading heading={heading} />)}
|
||||
</ol>
|
||||
</nav>
|
||||
</details>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import type { TocItem } from "@/utils/generateToc";
|
||||
|
||||
interface Props {
|
||||
heading: TocItem;
|
||||
}
|
||||
|
||||
const {
|
||||
heading: { children, depth, slug, text },
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<li class={`${depth > 2 ? "ms-2" : ""}`}>
|
||||
<a
|
||||
class={`line-clamp-2 hover:text-accent ${depth <= 2 ? "mt-3" : "mt-2 text-xs"}`}
|
||||
href={`#${slug}`}><span aria-hidden="true" class="me-0.5">#</span>{text}</a
|
||||
>
|
||||
{
|
||||
!!children.length && (
|
||||
<ol>
|
||||
{children.map((subheading) => (
|
||||
<Astro.self heading={subheading} />
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
</li>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!-- Parent: ../AGENTS.md -->
|
||||
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||
|
||||
# webmentions
|
||||
|
||||
## Purpose
|
||||
Display [webmentions](https://webmention.io/) (likes and comments) fetched for a post.
|
||||
|
||||
## Key Files
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `index.astro` | Entry component; gates rendering on the `WEBMENTION_URL` env var and fetches mentions. |
|
||||
| `Likes.astro` | Renders "like" webmentions. |
|
||||
| `Comments.astro` | Renders comment/reply webmentions. |
|
||||
|
||||
## For AI Agents
|
||||
|
||||
### Working In This Directory
|
||||
- Webmentions are optional and only active when the `WEBMENTION_*` client env vars are set (see `astro:env/client` usage in `BaseHead.astro`). Fetch/caching logic lives in `@/utils/webmentions.ts`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal
|
||||
- `@/utils/webmentions.ts`, `@/types` (webmention types).
|
||||
|
||||
<!-- MANUAL: -->
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import type { WebmentionsChildren } from "@/types";
|
||||
|
||||
interface Props {
|
||||
mentions: WebmentionsChildren[];
|
||||
}
|
||||
|
||||
const { mentions } = Astro.props;
|
||||
|
||||
const validComments = ["mention-of", "in-reply-to"];
|
||||
|
||||
const comments = mentions.filter(
|
||||
(mention) => validComments.includes(mention["wm-property"]) && mention.content?.text,
|
||||
);
|
||||
---
|
||||
|
||||
{
|
||||
!!comments.length && (
|
||||
<div>
|
||||
<p class="text-accent-2 mb-0">
|
||||
<strong>{comments.length}</strong> Mention{comments.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
<ul class="divide-global-text/20 mt-0 divide-y ps-0" role="list">
|
||||
{comments.map((mention) => (
|
||||
<li class="p-comment h-cite my-0 flex items-start gap-x-5 py-5">
|
||||
{mention.author?.photo && mention.author.photo !== "" ? (
|
||||
mention.author.url && mention.author.url !== "" ? (
|
||||
<a
|
||||
class="u-author not-prose ring-global-text hover:ring-link focus-visible:ring-link shrink-0 overflow-hidden rounded-full ring-2 hover:ring-4 focus-visible:ring-4"
|
||||
href={mention.author.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={mention.author.name}
|
||||
>
|
||||
<Image
|
||||
alt={mention.author?.name}
|
||||
class="u-photo my-0 h-12 w-12"
|
||||
height={48}
|
||||
src={mention.author?.photo}
|
||||
width={48}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<Image
|
||||
alt={mention.author?.name}
|
||||
class="u-photo my-0 h-12 w-12 rounded-full"
|
||||
height={48}
|
||||
src={mention.author?.photo}
|
||||
width={48}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
<div class="flex-auto">
|
||||
<div class="p-author h-card flex items-center justify-between gap-x-2">
|
||||
<p class="p-name text-accent-2 my-0 line-clamp-1 font-semibold">
|
||||
{mention.author?.name}
|
||||
</p>
|
||||
<a
|
||||
aria-labelledby="cmt-source"
|
||||
class="u-url not-prose hover:text-link"
|
||||
href={mention.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="hidden" id="cmt-source">
|
||||
Visit the source of this webmention
|
||||
</span>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
focusable="false"
|
||||
name="lucide:external-link"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="comment-content mt-1 mb-0 [word-break:break-word] whitespace-pre-wrap">
|
||||
{mention.content?.text}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import type { WebmentionsChildren } from "@/types";
|
||||
|
||||
interface Props {
|
||||
mentions: WebmentionsChildren[];
|
||||
}
|
||||
|
||||
const { mentions } = Astro.props;
|
||||
const MAX_LIKES = 10;
|
||||
|
||||
const likes = mentions.filter((mention) => mention["wm-property"] === "like-of");
|
||||
const likesToShow = likes
|
||||
.filter((like) => like.author?.photo && like.author.photo !== "")
|
||||
.slice(0, MAX_LIKES);
|
||||
---
|
||||
|
||||
{
|
||||
!!likes.length && (
|
||||
<div>
|
||||
<p class="text-accent-2 mb-0">
|
||||
<strong>{likes.length}</strong>
|
||||
{likes.length > 1 ? " People" : " Person"} liked this
|
||||
</p>
|
||||
{!!likesToShow.length && (
|
||||
<ul class="flex list-none flex-wrap overflow-hidden ps-2" role="list">
|
||||
{likesToShow.map((like) => (
|
||||
<li class="p-like h-cite -ms-2">
|
||||
<a
|
||||
class="u-url not-prose ring-global-text hover:ring-link focus-visible:ring-link relative inline-block overflow-hidden rounded-full ring-2 hover:z-10 hover:ring-4 focus-visible:z-10 focus-visible:ring-4"
|
||||
href={like.author?.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={like.author?.name}
|
||||
>
|
||||
<span class="p-author h-card">
|
||||
<Image
|
||||
alt={like.author!.name}
|
||||
class="u-photo my-0 inline-block h-12 w-12"
|
||||
height={48}
|
||||
src={like.author!.photo}
|
||||
width={48}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import { getWebmentionsForUrl } from "@/utils/webmentions";
|
||||
import Comments from "./Comments.astro";
|
||||
import Likes from "./Likes.astro";
|
||||
|
||||
const url = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
const webMentions = await getWebmentionsForUrl(`${url}`);
|
||||
|
||||
// Return if no webmentions
|
||||
if (!webMentions.length) return;
|
||||
---
|
||||
|
||||
<hr class="border-solid" />
|
||||
<h2 class="mb-8 before:hidden">Webmentions for this post</h2>
|
||||
<div class="space-y-10">
|
||||
<Likes mentions={webMentions} />
|
||||
<Comments mentions={webMentions} />
|
||||
</div>
|
||||
<p class="mt-8">
|
||||
Responses powered by{" "}
|
||||
<a href="https://webmention.io" rel="noreferrer" target="_blank">Webmentions</a>
|
||||
</p>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!-- Parent: ../AGENTS.md -->
|
||||
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||
|
||||
# layout
|
||||
|
||||
## Purpose
|
||||
Site-wide chrome rendered on every page by `src/layouts/Base.astro`.
|
||||
|
||||
## Key Files
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `Header.astro` | Top nav: avatar logo, site title, `menuLinks` nav, search, theme toggle, mobile menu button. |
|
||||
| `Footer.astro` | Three centered rows — nav links, social icon row (from `socialLinks`), copyright — with a top border. |
|
||||
|
||||
## For AI Agents
|
||||
|
||||
### Working In This Directory
|
||||
- **Header logo positioning:** the avatar is `position: absolute` and overflows left into the page margin. It is centered on the **header** block (not just the title) — this only works because the logo's containing block is the `<header>` (which is `relative`). Do **not** put a `filter`/`transform`/`position: relative` on the wrapping `<a>`, or it becomes the containing block and the logo mis-centers. The grayscale/hover effect therefore lives on the `<img>`, not the `<a>`.
|
||||
- Logo geometry was tuned against the header center; if you change the logo size (`sm:size-36`) or header padding (`sm:ps-28`), re-verify vertical centering and the left overflow (`sm:-start-[48px]`).
|
||||
- `menuLinks` and `socialLinks` come from `src/site.config.ts` — edit there, not here.
|
||||
|
||||
### Testing Requirements
|
||||
- Verify visually at desktop and mobile widths (the logo is inline on mobile, absolute/overflowing on `sm+`). Chrome DevTools MCP works for measuring element rects.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal
|
||||
- `@/site.config` (`menuLinks`, `socialLinks`, `siteConfig`), `astro-icon` for social/RSS icons.
|
||||
|
||||
<!-- MANUAL: -->
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { menuLinks, siteConfig, socialLinks } from "@/site.config";
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer
|
||||
class="text-muted border-global-text/15 mt-auto flex w-full flex-col items-center gap-y-6 border-t pt-8 pb-6 text-center text-sm font-semibold"
|
||||
>
|
||||
{/* Row 1: site nav */}
|
||||
<nav aria-labelledby="footer_links" class="flex flex-wrap justify-center gap-x-1 gap-y-1">
|
||||
<p id="footer_links" class="sr-only">More on this site</p>
|
||||
<a class="hover:text-global-text px-3 py-1 hover:underline" href="/skills/">Skills</a>
|
||||
{
|
||||
menuLinks.map((link) => (
|
||||
<a class="hover:text-global-text px-3 py-1 hover:underline" href={link.path}>
|
||||
{link.title}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
{/* Row 2: social icons */}
|
||||
<ul class="flex flex-wrap items-center justify-center gap-1">
|
||||
{
|
||||
socialLinks.map(({ friendlyName, isWebmention, link, name }) => (
|
||||
<li class="flex">
|
||||
<a
|
||||
class="hover:text-link inline-flex rounded-md p-2 transition-colors"
|
||||
href={link}
|
||||
rel={`me noreferrer ${isWebmention ? "authn" : ""}`}
|
||||
target={link.startsWith("mailto:") ? undefined : "_blank"}
|
||||
>
|
||||
<Icon aria-hidden="true" class="h-6 w-6" focusable="false" name={name} />
|
||||
<span class="sr-only">{friendlyName}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
{/* Row 3: copyright */}
|
||||
<p class="text-xs font-normal">© {year} {siteConfig.author}</p>
|
||||
</footer>
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
import Search from "@/components/Search.astro";
|
||||
import ThemeToggle from "@/components/ThemeToggle.astro";
|
||||
import { menuLinks } from "@/site.config";
|
||||
import { siteConfig } from "../../site.config";
|
||||
---
|
||||
|
||||
<header class="group relative mb-12 flex items-center sm:mb-28 sm:ps-28" id="main-header">
|
||||
<div class="flex sm:flex-col">
|
||||
<a
|
||||
aria-current={Astro.url.pathname === "/" ? "page" : false}
|
||||
class="inline-flex items-center sm:inline-block"
|
||||
href="/"
|
||||
>
|
||||
<img
|
||||
alt={`${siteConfig.title} logo`}
|
||||
class="me-3 size-14 rounded-full sm:absolute sm:-start-[48px] sm:top-1/2 sm:me-0 sm:size-36 sm:-translate-y-1/2 sm:grayscale sm:hover:filter-none"
|
||||
height="144"
|
||||
width="144"
|
||||
src="/avatar.png"
|
||||
/>
|
||||
<span class="text-xl font-bold sm:text-2xl">{siteConfig.title}</span>
|
||||
</a>
|
||||
<nav
|
||||
aria-label="Main menu"
|
||||
class="bg-global-bg absolute -inset-x-4 top-12 hidden flex-col divide-y px-2 py-4 group-[.menu-open]:z-50 group-[.menu-open]:flex sm:static sm:z-auto sm:-ms-4 sm:mt-1 sm:flex sm:flex-row sm:divide-x sm:divide-y-0 sm:bg-transparent sm:p-0"
|
||||
id="navigation-menu"
|
||||
>
|
||||
{
|
||||
menuLinks.map((link) => (
|
||||
<a
|
||||
aria-current={Astro.url.pathname === link.path ? "page" : false}
|
||||
class="text-accent px-2 py-4 font-semibold sm:px-4 sm:py-0 sm:underline-offset-2 sm:hover:underline"
|
||||
href={link.path}
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
<Search />
|
||||
<ThemeToggle />
|
||||
<mobile-button>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
class="group relative ms-4 h-7 w-7 sm:invisible sm:hidden"
|
||||
id="toggle-navigation-menu"
|
||||
type="button"
|
||||
>
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="absolute start-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 transition-all group-aria-expanded:scale-0 group-aria-expanded:opacity-0"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="line-svg"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M3.75 9h16.5m-16.5 6.75h16.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="text-accent absolute start-1/2 top-1/2 h-full w-full -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all group-aria-expanded:scale-100 group-aria-expanded:opacity-100"
|
||||
class="text-accent"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
id="cross-svg"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</mobile-button>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
import { toggleClass } from "@/utils/domElement";
|
||||
|
||||
class MobileNavBtn extends HTMLElement {
|
||||
#menuOpen: boolean = false;
|
||||
|
||||
connectedCallback() {
|
||||
const headerEl = document.getElementById("main-header")!;
|
||||
const mobileButtonEl = this.querySelector<HTMLButtonElement>("button");
|
||||
|
||||
mobileButtonEl?.addEventListener("click", () => {
|
||||
if (headerEl) toggleClass(headerEl, "menu-open");
|
||||
this.#menuOpen = !this.#menuOpen;
|
||||
mobileButtonEl.setAttribute("aria-expanded", this.#menuOpen.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("mobile-button", MobileNavBtn);
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!-- Parent: ../AGENTS.md -->
|
||||
<!-- Generated: 2026-06-30 | Updated: 2026-06-30 -->
|
||||
|
||||
# project
|
||||
|
||||
## Purpose
|
||||
Rendering for the `projects` collection.
|
||||
|
||||
## Key Files
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `Project.astro` | Polymorphic component for a project. In `isPreview` mode it renders a compact card linking to `/projects/<id>`; in full mode it shows the title, date, `link`/`repo` buttons, rendered body, and `ExperienceTags`. |
|
||||
|
||||
## For AI Agents
|
||||
|
||||
### Working In This Directory
|
||||
- Prop is `project: CollectionEntry<"projects">` (note: the source dir is `src/content/projects`).
|
||||
- Used by `src/pages/projects/[...page].astro` (preview list), `[...slug].astro` (full), and the homepage.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal
|
||||
- `@/components/ExperienceTags.astro`, `@/components/FormattedDate.astro`.
|
||||
|
||||
<!-- MANUAL: -->
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import { type CollectionEntry, getEntries, render } from "astro:content";
|
||||
import type { HTMLTag, Polymorphic } from "astro/types";
|
||||
import TagList from "@/components/TagList.astro";
|
||||
|
||||
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
|
||||
project: CollectionEntry<"projects">;
|
||||
isPreview?: boolean | undefined;
|
||||
};
|
||||
|
||||
const { as: Tag = "div", project, isPreview = false } = Astro.props;
|
||||
const { Content } = await render(project);
|
||||
const { title, startDate, endDate, link, repo, tags, experiences } = project.data;
|
||||
const range = `${startDate.getFullYear()} – ${endDate ? endDate.getFullYear() : "Present"}`;
|
||||
const expEntries = experiences.length ? await getEntries(experiences) : [];
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[isPreview && "bg-global-text/5 inline-grid rounded-md px-4 py-3"]}
|
||||
data-pagefind-body={isPreview ? false : true}
|
||||
>
|
||||
<Tag class="title" class:list={{ "text-base": isPreview }}>
|
||||
{
|
||||
isPreview ? (
|
||||
<a class="cactus-link" href={`/projects/${project.id}/`}>
|
||||
{title}
|
||||
</a>
|
||||
) : (
|
||||
<>{title}</>
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
<p class="text-muted text-sm">
|
||||
<span>{range}</span>{expEntries.length > 0 && (
|
||||
<span> ({expEntries.map((exp, i) => (
|
||||
<Fragment>{i > 0 && ", "}<a class="cactus-link" href={`/experience/${exp.id}/`}>{exp.data.title}</a></Fragment>
|
||||
))})</span>
|
||||
)}
|
||||
</p>
|
||||
{
|
||||
!isPreview && (link || repo) && (
|
||||
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{link && (
|
||||
<a class="cactus-link" href={link} rel="noopener noreferrer" target="_blank">
|
||||
Visit →
|
||||
</a>
|
||||
)}
|
||||
{repo && (
|
||||
<a class="cactus-link" href={repo} rel="noopener noreferrer" target="_blank">
|
||||
Source →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div
|
||||
class="prose prose-sm prose-cactus mt-4 max-w-none [&>p:last-of-type]:mb-0"
|
||||
class:list={{ "line-clamp-6": isPreview }}
|
||||
>
|
||||
<Content />
|
||||
</div>
|
||||
{
|
||||
!isPreview && tags.length > 0 && (
|
||||
<TagList tags={tags} class="border-global-text/10 mt-6 border-t pt-6" />
|
||||
)
|
||||
}
|
||||
</article>
|
||||
Reference in new issue
Block a user