diff --git a/src/components/ArticleLayout.tsx b/src/components/ArticleLayout.tsx
deleted file mode 100644
index 0b0f4b8..0000000
--- a/src/components/ArticleLayout.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-'use client'
-
-import { useContext } from 'react'
-import { useRouter } from 'next/navigation'
-
-import { AppContext } from '@/app/providers'
-import { Container } from '@/components/Container'
-import { Prose } from '@/components/Prose'
-import { type ArticleWithSlug } from '@/lib/articles'
-import { formatDate } from '@/lib/formatDate'
-
-function ArrowLeftIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
-
-export function ArticleLayout({
- article,
- children,
-}: {
- article: ArticleWithSlug
- children: React.ReactNode
-}) {
- let router = useRouter()
- let { previousPathname } = useContext(AppContext)
-
- return (
-
-
-
- {previousPathname && (
-
- )}
-
-
-
- {article.title}
-
-
-
-
- {children}
-
-
-
-
-
- )
-}
diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro
new file mode 100644
index 0000000..5b48687
--- /dev/null
+++ b/src/components/BaseHead.astro
@@ -0,0 +1,86 @@
+---
+import { WEBMENTION_PINGBACK, WEBMENTION_URL } from "astro:env/client";
+import { siteConfig } from "@/site.config";
+import type { SiteMeta } from "@/types";
+import "@/styles/global.css";
+
+type Props = SiteMeta;
+
+const { articleDate, description, ogImage, 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;
+---
+
+
+
+
{siteTitle}
+
+{/* Icons */}
+
+{
+ import.meta.env.PROD && (
+ <>
+ {/* Favicon & Apple Icon, Generated at build */}
+
+
+ {/* Manifest */}
+
+ >
+ )
+}
+
+{/* Canonical URL */}
+
+
+{/* Primary Meta Tags */}
+
+
+
+
+{/* Open Graph / Facebook */}
+
+
+
+
+
+
+
+
+
+{
+ articleDate && (
+ <>
+
+
+ >
+ )
+}
+
+{/* Twitter */}
+
+
+
+
+
+
+{/* Sitemap */}
+
+
+{/* RSS auto-discovery */}
+
+
+
+{/* Webmentions */}
+{
+ WEBMENTION_URL && (
+ <>
+
+ {WEBMENTION_PINGBACK && }
+ >
+ )
+}
+
+
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
deleted file mode 100644
index a17201c..0000000
--- a/src/components/Button.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import Link from 'next/link'
-import clsx from 'clsx'
-
-const variantStyles = {
- primary:
- 'bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70',
- secondary:
- 'bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 active:bg-zinc-100 active:text-zinc-900/60 dark:bg-zinc-800/50 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:active:bg-zinc-800/50 dark:active:text-zinc-50/70',
-}
-
-type ButtonProps = {
- variant?: keyof typeof variantStyles
-} & (
- | (React.ComponentPropsWithoutRef<'button'> & { href?: undefined })
- | React.ComponentPropsWithoutRef
-)
-
-export function Button({
- variant = 'primary',
- className,
- ...props
-}: ButtonProps) {
- className = clsx(
- 'inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none',
- variantStyles[variant],
- className,
- )
-
- return typeof props.href === 'undefined' ? (
-
- ) : (
-
- )
-}
diff --git a/src/components/Card.tsx b/src/components/Card.tsx
deleted file mode 100644
index 712268a..0000000
--- a/src/components/Card.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import Link from 'next/link'
-import clsx from 'clsx'
-
-function ChevronRightIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
-
-export function Card({
- as,
- className,
- children,
-}: Omit, 'as' | 'className'> & {
- as?: T
- className?: string
-}) {
- let Component = as ?? 'div'
-
- return (
-
- {children}
-
- )
-}
-
-Card.Link = function CardLink({
- children,
- ...props
-}: React.ComponentPropsWithoutRef) {
- return (
- <>
-
-
-
- {children}
-
- >
- )
-}
-
-Card.Title = function CardTitle({
- as,
- href,
- children,
-}: Omit, 'as' | 'href'> & {
- as?: T
- href?: string
-}) {
- let Component = as ?? 'h2'
-
- return (
-
- {href ? {children} : children}
-
- )
-}
-
-Card.Description = function CardDescription({
- children,
-}: {
- children: React.ReactNode
-}) {
- return (
-
- {children}
-
- )
-}
-
-Card.Cta = function CardCta({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
-
- )
-}
-
-Card.Eyebrow = function CardEyebrow({
- as,
- decorate = false,
- className,
- children,
- ...props
-}: Omit, 'as' | 'decorate'> & {
- as?: T
- decorate?: boolean
-}) {
- let Component = as ?? 'p'
-
- return (
-
- {decorate && (
-
-
-
- )}
- {children}
-
- )
-}
diff --git a/src/components/Container.tsx b/src/components/Container.tsx
deleted file mode 100644
index 24b0b59..0000000
--- a/src/components/Container.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { forwardRef } from 'react'
-import clsx from 'clsx'
-
-export const ContainerOuter = forwardRef<
- React.ElementRef<'div'>,
- React.ComponentPropsWithoutRef<'div'>
->(function OuterContainer({ className, children, ...props }, ref) {
- return (
-
- )
-})
-
-export const ContainerInner = forwardRef<
- React.ElementRef<'div'>,
- React.ComponentPropsWithoutRef<'div'>
->(function InnerContainer({ className, children, ...props }, ref) {
- return (
-
- )
-})
-
-export const Container = forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(function Container({ children, ...props }, ref) {
- return (
-
- {children}
-
- )
-})
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
deleted file mode 100644
index 823e119..0000000
--- a/src/components/Footer.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import Link from 'next/link'
-
-import { ContainerInner, ContainerOuter } from '@/components/Container'
-
-function NavLink({
- href,
- children,
-}: {
- href: string
- children: React.ReactNode
-}) {
- return (
-
- {children}
-
- )
-}
-
-export function Footer() {
- return (
-
- )
-}
diff --git a/src/components/FormattedDate.astro b/src/components/FormattedDate.astro
new file mode 100644
index 0000000..45de273
--- /dev/null
+++ b/src/components/FormattedDate.astro
@@ -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();
+---
+
+
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
deleted file mode 100644
index 75b8252..0000000
--- a/src/components/Header.tsx
+++ /dev/null
@@ -1,461 +0,0 @@
-'use client'
-
-import { Fragment, useEffect, useRef, useState } from 'react'
-import Image from 'next/image'
-import Link from 'next/link'
-import { usePathname } from 'next/navigation'
-import { useTheme } from 'next-themes'
-import { Popover, Transition } from '@headlessui/react'
-import clsx from 'clsx'
-
-import { Container } from '@/components/Container'
-import avatarImage from '@/images/avatar.jpg'
-
-function CloseIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
-
-function ChevronDownIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
-
-function SunIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
-
-function MoonIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
-
-function MobileNavItem({
- href,
- children,
-}: {
- href: string
- children: React.ReactNode
-}) {
- return (
-
-
- {children}
-
-
- )
-}
-
-function MobileNavigation(
- props: React.ComponentPropsWithoutRef,
-) {
- return (
-
-
- Menu
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-function NavItem({
- href,
- children,
-}: {
- href: string
- children: React.ReactNode
-}) {
- let isActive = usePathname() === href
-
- return (
-
-
- {children}
- {isActive && (
-
- )}
-
-
- )
-}
-
-function DesktopNavigation(props: React.ComponentPropsWithoutRef<'nav'>) {
- return (
-
- )
-}
-
-function ThemeToggle() {
- let { resolvedTheme, setTheme } = useTheme()
- let otherTheme = resolvedTheme === 'dark' ? 'light' : 'dark'
- let [mounted, setMounted] = useState(false)
-
- useEffect(() => {
- setMounted(true)
- }, [])
-
- return (
-
- )
-}
-
-function clamp(number: number, a: number, b: number) {
- let min = Math.min(a, b)
- let max = Math.max(a, b)
- return Math.min(Math.max(number, min), max)
-}
-
-function AvatarContainer({
- className,
- ...props
-}: React.ComponentPropsWithoutRef<'div'>) {
- return (
-
- )
-}
-
-function Avatar({
- large = false,
- className,
- ...props
-}: Omit, 'href'> & {
- large?: boolean
-}) {
- return (
-
-
-
- )
-}
-
-export function Header() {
- let isHomePage = usePathname() === '/'
-
- let headerRef = useRef>(null)
- let avatarRef = useRef>(null)
- let isInitial = useRef(true)
-
- useEffect(() => {
- let downDelay = avatarRef.current?.offsetTop ?? 0
- let upDelay = 64
-
- function setProperty(property: string, value: string) {
- document.documentElement.style.setProperty(property, value)
- }
-
- function removeProperty(property: string) {
- document.documentElement.style.removeProperty(property)
- }
-
- function updateHeaderStyles() {
- if (!headerRef.current) {
- return
- }
-
- let { top, height } = headerRef.current.getBoundingClientRect()
- let scrollY = clamp(
- window.scrollY,
- 0,
- document.body.scrollHeight - window.innerHeight,
- )
-
- if (isInitial.current) {
- setProperty('--header-position', 'sticky')
- }
-
- setProperty('--content-offset', `${downDelay}px`)
-
- if (isInitial.current || scrollY < downDelay) {
- setProperty('--header-height', `${downDelay + height}px`)
- setProperty('--header-mb', `${-downDelay}px`)
- } else if (top + height < -upDelay) {
- let offset = Math.max(height, scrollY - upDelay)
- setProperty('--header-height', `${offset}px`)
- setProperty('--header-mb', `${height - offset}px`)
- } else if (top === 0) {
- setProperty('--header-height', `${scrollY + height}px`)
- setProperty('--header-mb', `${-scrollY}px`)
- }
-
- if (top === 0 && scrollY > 0 && scrollY >= downDelay) {
- setProperty('--header-inner-position', 'fixed')
- removeProperty('--header-top')
- removeProperty('--avatar-top')
- } else {
- removeProperty('--header-inner-position')
- setProperty('--header-top', '0px')
- setProperty('--avatar-top', '0px')
- }
- }
-
- function updateAvatarStyles() {
- if (!isHomePage) {
- return
- }
-
- let fromScale = 1
- let toScale = 36 / 64
- let fromX = 0
- let toX = 2 / 16
-
- let scrollY = downDelay - window.scrollY
-
- let scale = (scrollY * (fromScale - toScale)) / downDelay + toScale
- scale = clamp(scale, fromScale, toScale)
-
- let x = (scrollY * (fromX - toX)) / downDelay + toX
- x = clamp(x, fromX, toX)
-
- setProperty(
- '--avatar-image-transform',
- `translate3d(${x}rem, 0, 0) scale(${scale})`,
- )
-
- let borderScale = 1 / (toScale / scale)
- let borderX = (-toX + x) * borderScale
- let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})`
-
- setProperty('--avatar-border-transform', borderTransform)
- setProperty('--avatar-border-opacity', scale === toScale ? '1' : '0')
- }
-
- function updateStyles() {
- updateHeaderStyles()
- updateAvatarStyles()
- isInitial.current = false
- }
-
- updateStyles()
- window.addEventListener('scroll', updateStyles, { passive: true })
- window.addEventListener('resize', updateStyles)
-
- return () => {
- window.removeEventListener('scroll', updateStyles)
- window.removeEventListener('resize', updateStyles)
- }
- }, [isHomePage])
-
- return (
- <>
-
- {isHomePage && (
- <>
-
-
-
-
- >
- )}
-
-
-
-
- {!isHomePage && (
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
- {isHomePage && (
-
- )}
- >
- )
-}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
deleted file mode 100644
index 12c48eb..0000000
--- a/src/components/Layout.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Footer } from '@/components/Footer'
-import { Header } from '@/components/Header'
-
-export function Layout({ children }: { children: React.ReactNode }) {
- return (
- <>
-
-
-
- {children}
-
-
- >
- )
-}
diff --git a/src/components/Paginator.astro b/src/components/Paginator.astro
new file mode 100644
index 0000000..f05638f
--- /dev/null
+++ b/src/components/Paginator.astro
@@ -0,0 +1,29 @@
+---
+import type { PaginationLink } from "@/types";
+
+interface Props {
+ nextUrl?: PaginationLink;
+ prevUrl?: PaginationLink;
+}
+
+const { nextUrl, prevUrl } = Astro.props;
+---
+
+{
+ (prevUrl || nextUrl) && (
+
+ )
+}
diff --git a/src/components/Prose.tsx b/src/components/Prose.tsx
deleted file mode 100644
index 843ac9c..0000000
--- a/src/components/Prose.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import clsx from 'clsx'
-
-export function Prose({
- className,
- ...props
-}: React.ComponentPropsWithoutRef<'div'>) {
- return (
-
- )
-}
diff --git a/src/components/Search.astro b/src/components/Search.astro
new file mode 100644
index 0000000..e374249
--- /dev/null
+++ b/src/components/Search.astro
@@ -0,0 +1,19 @@
+---
+import "@/styles/blocks/search.css";
+---
+
+
+
+
+
+
diff --git a/src/components/Section.tsx b/src/components/Section.tsx
deleted file mode 100644
index 4a2fc73..0000000
--- a/src/components/Section.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useId } from 'react'
-
-export function Section({
- title,
- children,
-}: {
- title: string
- children: React.ReactNode
-}) {
- let id = useId()
-
- return (
-
-
-
- {title}
-
-
{children}
-
-
- )
-}
diff --git a/src/components/SimpleLayout.tsx b/src/components/SimpleLayout.tsx
deleted file mode 100644
index 2023a38..0000000
--- a/src/components/SimpleLayout.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Container } from '@/components/Container'
-
-export function SimpleLayout({
- title,
- intro,
- children,
-}: {
- title: string
- intro: string
- children?: React.ReactNode
-}) {
- return (
-
-
-
- {title}
-
-
- {intro}
-
-
- {children && {children}
}
-
- )
-}
diff --git a/src/components/SkipLink.astro b/src/components/SkipLink.astro
new file mode 100644
index 0000000..dae6bad
--- /dev/null
+++ b/src/components/SkipLink.astro
@@ -0,0 +1,3 @@
+skip to content
+
diff --git a/src/components/SocialIcons.tsx b/src/components/SocialIcons.tsx
deleted file mode 100644
index ae7daab..0000000
--- a/src/components/SocialIcons.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-export function TwitterIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
-
-export function MediumIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
-
-export function InstagramIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
-
-export function GitHubIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
-
-export function LinkedInIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- )
-}
diff --git a/src/components/SocialList.astro b/src/components/SocialList.astro
new file mode 100644
index 0000000..00e7f97
--- /dev/null
+++ b/src/components/SocialList.astro
@@ -0,0 +1,42 @@
+---
+import { Icon } from "astro-icon/components";
+
+/**
+ Uses https://www.astroicon.dev/getting-started/
+ Find icons via guide: https://www.astroicon.dev/guides/customization/#open-source-icon-sets
+ Only installed pack is: @iconify-json/mdi
+*/
+const socialLinks: {
+ friendlyName: string;
+ isWebmention?: boolean;
+ link: string;
+ name: string;
+}[] = [
+ {
+ friendlyName: "Github",
+ link: "https://github.com/chrismwilliams/astro-cactus",
+ name: "mdi:github",
+ },
+];
+---
+
+
+
Find me on
+
+ {
+ socialLinks.map(({ friendlyName, isWebmention, link, name }) => (
+ -
+
+
+ {friendlyName}
+
+
+ ))
+ }
+
+
diff --git a/src/components/ThemeProvider.astro b/src/components/ThemeProvider.astro
new file mode 100644
index 0000000..0aad79e
--- /dev/null
+++ b/src/components/ThemeProvider.astro
@@ -0,0 +1,48 @@
+{/* Inlined to avoid FOUC. This is a parser blocking script. */}
+
diff --git a/src/components/ThemeToggle.astro b/src/components/ThemeToggle.astro
new file mode 100644
index 0000000..8316886
--- /dev/null
+++ b/src/components/ThemeToggle.astro
@@ -0,0 +1,90 @@
+
+
+
+
+
diff --git a/src/components/blog/Masthead.astro b/src/components/blog/Masthead.astro
new file mode 100644
index 0000000..10783cd
--- /dev/null
+++ b/src/components/blog/Masthead.astro
@@ -0,0 +1,84 @@
+---
+import { Image } from "astro:assets";
+import type { CollectionEntry } from "astro:content";
+import FormattedDate from "@/components/FormattedDate.astro";
+
+interface Props {
+ content: CollectionEntry<"post">;
+ readingTime: string;
+}
+
+const {
+ content: { data },
+ readingTime,
+} = Astro.props;
+
+const dateTimeOptions: Intl.DateTimeFormatOptions = {
+ month: "long",
+};
+---
+
+{
+ data.coverImage && (
+
+
+
+ )
+}
+{data.draft ? (Draft) : null}
+
+ {data.title}
+
+
+
+ /{" "}
+ {readingTime}
+
+ {
+ data.updatedDate && (
+
+ Updated:
+
+
+ )
+ }
+
+{
+ !!data.tags?.length && (
+
+ )
+}
diff --git a/src/components/blog/PostPreview.astro b/src/components/blog/PostPreview.astro
new file mode 100644
index 0000000..b31a712
--- /dev/null
+++ b/src/components/blog/PostPreview.astro
@@ -0,0 +1,21 @@
+---
+import type { CollectionEntry } from "astro:content";
+import type { HTMLTag, Polymorphic } from "astro/types";
+import FormattedDate from "@/components/FormattedDate.astro";
+
+type Props = Polymorphic<{ as: Tag }> & {
+ post: CollectionEntry<"post">;
+ withDesc?: boolean;
+};
+
+const { as: Tag = "div", post, withDesc = false } = Astro.props;
+---
+
+
+
+ {post.data.draft && (Draft) }
+
+ {post.data.title}
+
+
+{withDesc && {post.data.description}
}
diff --git a/src/components/blog/TOC.astro b/src/components/blog/TOC.astro
new file mode 100644
index 0000000..2a45124
--- /dev/null
+++ b/src/components/blog/TOC.astro
@@ -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);
+---
+
+
+ Table of Contents
+
+
diff --git a/src/components/blog/TOCHeading.astro b/src/components/blog/TOCHeading.astro
new file mode 100644
index 0000000..b9dd486
--- /dev/null
+++ b/src/components/blog/TOCHeading.astro
@@ -0,0 +1,27 @@
+---
+import type { TocItem } from "@/utils/generateToc";
+
+interface Props {
+ heading: TocItem;
+}
+
+const {
+ heading: { children, depth, slug, text },
+} = Astro.props;
+---
+
+ 2 ? "ms-2" : ""}`}>
+ #{text}
+ {
+ !!children.length && (
+
+ {children.map((subheading) => (
+
+ ))}
+
+ )
+ }
+
diff --git a/src/components/blog/webmentions/Comments.astro b/src/components/blog/webmentions/Comments.astro
new file mode 100644
index 0000000..838c562
--- /dev/null
+++ b/src/components/blog/webmentions/Comments.astro
@@ -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 && (
+
+
+ {comments.length} Mention{comments.length > 1 ? "s" : ""}
+
+
+ {comments.map((mention) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/blog/webmentions/Likes.astro b/src/components/blog/webmentions/Likes.astro
new file mode 100644
index 0000000..7862c43
--- /dev/null
+++ b/src/components/blog/webmentions/Likes.astro
@@ -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 && (
+
+
+ {likes.length}
+ {likes.length > 1 ? " People" : " Person"} liked this
+
+ {!!likesToShow.length && (
+
+ {likesToShow.map((like) => (
+ -
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/components/blog/webmentions/index.astro b/src/components/blog/webmentions/index.astro
new file mode 100644
index 0000000..232b4f3
--- /dev/null
+++ b/src/components/blog/webmentions/index.astro
@@ -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;
+---
+
+
+Webmentions for this post
+
+
+
+
+
+ Responses powered by{" "}
+ Webmentions
+
diff --git a/src/components/layout/Footer.astro b/src/components/layout/Footer.astro
new file mode 100644
index 0000000..04960a9
--- /dev/null
+++ b/src/components/layout/Footer.astro
@@ -0,0 +1,24 @@
+---
+import { menuLinks, siteConfig } from "@/site.config";
+
+const year = new Date().getFullYear();
+---
+
+
diff --git a/src/components/layout/Header.astro b/src/components/layout/Header.astro
new file mode 100644
index 0000000..965b413
--- /dev/null
+++ b/src/components/layout/Header.astro
@@ -0,0 +1,117 @@
+---
+import Search from "@/components/Search.astro";
+import ThemeToggle from "@/components/ThemeToggle.astro";
+import { menuLinks } from "@/site.config";
+import { siteConfig } from "../../site.config";
+---
+
+
+
+
diff --git a/src/components/note/Note.astro b/src/components/note/Note.astro
new file mode 100644
index 0000000..e415692
--- /dev/null
+++ b/src/components/note/Note.astro
@@ -0,0 +1,46 @@
+---
+import { type CollectionEntry, render } from "astro:content";
+import type { HTMLTag, Polymorphic } from "astro/types";
+import FormattedDate from "@/components/FormattedDate.astro";
+
+type Props = Polymorphic<{ as: Tag }> & {
+ note: CollectionEntry<"note">;
+ isPreview?: boolean | undefined;
+};
+
+const { as: Tag = "div", note, isPreview = false } = Astro.props;
+const { Content } = await render(note);
+---
+
+
+
+ {
+ isPreview ? (
+
+ {note.data.title}
+
+ ) : (
+ <>{note.data.title}>
+ )
+ }
+
+
+
+
+
+
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
new file mode 100644
index 0000000..a776168
--- /dev/null
+++ b/src/layouts/Base.astro
@@ -0,0 +1,54 @@
+---
+import BaseHead from "@/components/BaseHead.astro";
+import Footer from "@/components/layout/Footer.astro";
+import Header from "@/components/layout/Header.astro";
+import SkipLink from "@/components/SkipLink.astro";
+import ThemeProvider from "@/components/ThemeProvider.astro";
+import { siteConfig } from "@/site.config";
+import type { SiteMeta } from "@/types";
+
+interface Props {
+ meta: SiteMeta;
+}
+
+const {
+ meta: { articleDate, description = siteConfig.description, ogImage, title },
+} = Astro.props;
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layouts/BlogPost.astro b/src/layouts/BlogPost.astro
new file mode 100644
index 0000000..f5ea60b
--- /dev/null
+++ b/src/layouts/BlogPost.astro
@@ -0,0 +1,80 @@
+---
+import { type CollectionEntry, render } from "astro:content";
+
+import Masthead from "@/components/blog/Masthead.astro";
+import TOC from "@/components/blog/TOC.astro";
+import WebMentions from "@/components/blog/webmentions/index.astro";
+
+import BaseLayout from "./Base.astro";
+
+interface Props {
+ post: CollectionEntry<"post">;
+}
+
+const { post } = Astro.props;
+const { ogImage, title, description, updatedDate, publishDate } = post.data;
+const socialImage = ogImage ?? `/og-image/${post.id}.png`;
+const articleDate = updatedDate?.toISOString() ?? publishDate.toISOString();
+const { headings, remarkPluginFrontmatter } = await render(post);
+const readingTime: string = remarkPluginFrontmatter.readingTime;
+---
+
+
+
+
+
+ {!!headings.length &&
}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/styles/blocks/search.css b/src/styles/blocks/search.css
new file mode 100644
index 0000000..e92dc30
--- /dev/null
+++ b/src/styles/blocks/search.css
@@ -0,0 +1,92 @@
+@import "@pagefind/component-ui/css" layer(pagefind);
+
+:root {
+ --pf-text-muted: var(--color-global-text);
+ --pf-background: var(--color-global-bg);
+ --pf-border-focus: var(--color-accent-2);
+ --pf-outline-focus: var(--color-accent);
+ --pf-outline-offset: none;
+ --pf-font: var(--font-mono);
+ --pf-text: var(--color-global-text);
+ --pf-text-secondary: var(--color-muted);
+ --pf-skeleton: var(--color-global-bg);
+ --pf-icon-search: url("data:image/svg+xml,%3Csvg fill='none' height='16' width='16' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath d='M0 0h24v24H0z' stroke='none'%3E%3C/path%3E%3Cpath d='M3 10a7 7 0 1 0 14 0 7 7 0 1 0-14 0M21 21l-6-6'%3E%3C/path%3E%3C/svg%3E");
+}
+
+.pf-trigger-btn {
+ --pf-background: #0000;
+ --pf-border-focus: #0000;
+ --pf-hover: var(--color-accent);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: calc(var(--spacing) * 9);
+ height: calc(var(--spacing) * 9);
+ padding: 8px;
+ accent-color: var(--color-accent);
+ border: 0;
+ border-radius: 0;
+
+ &:hover {
+ @media (hover: hover) {
+ --pf-text-muted: var(--color-accent);
+ color: var(--color-accent);
+ }
+ }
+ &:focus-visible {
+ outline: auto;
+ }
+
+ .pf-trigger-icon {
+ width: calc(var(--spacing) * 7);
+ height: calc(var(--spacing) * 7);
+ }
+}
+
+dialog.pf-modal::backdrop {
+ backdrop-filter: blur(8px);
+}
+
+.pf-modal-body {
+ --pf-hover: var(--color-global-bg);
+
+ .pf-result-link,
+ .pf-heading-link {
+ background-size: 100% 6px;
+ background-position: bottom;
+ background-repeat: repeat-x;
+ background-image: linear-gradient(
+ transparent,
+ transparent 5px,
+ var(--color-global-text) 5px,
+ var(--color-global-text)
+ );
+
+ &:hover {
+ text-decoration: none;
+ background-image: linear-gradient(
+ transparent,
+ transparent 4px,
+ var(--color-link) 4px,
+ var(--color-link)
+ );
+ }
+ }
+
+ .pf-heading-link {
+ align-self: flex-start;
+ }
+
+ .pf-heading-excerpt {
+ mark {
+ --pf-mark: var(--color-accent-2);
+ font-weight: var(--font-weight-semibold);
+ }
+ }
+}
+
+.pf-keyboard-hints {
+ kbd {
+ color: var(--color-zinc-800);
+ }
+}
diff --git a/src/styles/components/admonition.css b/src/styles/components/admonition.css
new file mode 100644
index 0000000..429a79f
--- /dev/null
+++ b/src/styles/components/admonition.css
@@ -0,0 +1,65 @@
+.admonition {
+ --admonition-color: var(--tw-prose-quotes);
+ @apply border-s-2 border-(--admonition-color) py-4 ps-4;
+
+ .admonition-title {
+ @apply my-0! flex items-center gap-2 text-base font-bold text-(--admonition-color) capitalize;
+ &:before {
+ @apply inline-block h-4 w-4 shrink-0 overflow-visible bg-(--admonition-color) align-middle content-[''];
+ mask-size: contain;
+ mask-position: center;
+ mask-repeat: no-repeat;
+ }
+ }
+
+ .admonition-content {
+ > :last-child {
+ @apply mb-0!;
+ }
+ }
+
+ &[data-admonition-type="note"] {
+ --admonition-color: var(--color-blue-400);
+ @apply bg-blue-400/5;
+
+ .admonition-title::before {
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath fill='var(--admonitions-color-tip)' d='M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'%3E%3C/path%3E%3C/svg%3E");
+ }
+ }
+
+ &[data-admonition-type="tip"] {
+ --admonition-color: var(--color-lime-500);
+ @apply bg-lime-500/5;
+
+ .admonition-title::before {
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z'%3E%3C/path%3E%3C/svg%3E");
+ }
+ }
+
+ &[data-admonition-type="important"] {
+ --admonition-color: var(--color-purple-400);
+ @apply bg-purple-400/5;
+
+ .admonition-title::before {
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z'%3E%3C/path%3E%3C/svg%3E");
+ }
+ }
+
+ &[data-admonition-type="caution"] {
+ --admonition-color: var(--color-orange-400);
+ @apply bg-orange-400/5;
+
+ .admonition-title::before {
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'%3E%3C/path%3E%3C/svg%3E");
+ }
+ }
+
+ &[data-admonition-type="warning"] {
+ --admonition-color: var(--color-red-500);
+ @apply bg-red-500/5;
+
+ .admonition-title::before {
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z'%3E%3C/path%3E%3C/svg%3E");
+ }
+ }
+}
diff --git a/src/styles/components/github-card.css b/src/styles/components/github-card.css
new file mode 100644
index 0000000..86c4468
--- /dev/null
+++ b/src/styles/components/github-card.css
@@ -0,0 +1,79 @@
+.github-card {
+ @apply bg-global-text/5 rounded-md px-4 py-3;
+
+ .gh-title {
+ @apply relative flex items-center gap-2 text-base;
+
+ .gh-avatar {
+ @apply bg-global-text/20 h-6 w-6 flex-none rounded-full bg-none bg-cover bg-center;
+ }
+ .gh-text {
+ @apply line-clamp-2;
+ &:after {
+ @apply absolute inset-0 content-[''];
+ }
+ }
+ .gh-icon {
+ @apply bg-global-text pointer-events-none ms-auto h-6 w-6 flex-none;
+ mask:
+ url("data:image/svg+xml;utf8,");
+ }
+ .gh-forks {
+ --chip-image: url("data:image/svg+xml;utf8,");
+ }
+ .gh-license {
+ --chip-image: url("data:image/svg+xml;utf8,");
+ }
+ .gh-followers {
+ --chip-image: url("data:image/svg+xml;utf8,");
+ }
+ .gh-repositories {
+ --chip-image: url("data:image/svg+xml;utf8,");
+ }
+ .gh-language,
+ .gh-region {
+ @apply ms-auto;
+ }
+ }
+
+ &.gh-loading {
+ .gh-title .gh-avatar,
+ .gh-description,
+ .gh-chips span {
+ @apply bg-global-text/50 animate-pulse rounded-xl text-transparent;
+ }
+ .gh-chips span:before {
+ @apply bg-transparent;
+ }
+ }
+
+ &.gh-error {
+ .gh-avatar,
+ .gh-description,
+ .gh-chips {
+ @apply hidden;
+ }
+ }
+}
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 0000000..3c84d9b
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1,141 @@
+@layer theme, base, pagefind, components, utilities;
+
+@import "tailwindcss/theme.css" layer(theme);
+@import "tailwindcss/preflight.css" layer(base);
+@import "tailwindcss/utilities.css" layer(utilities);
+
+/* tailwind to ignore satori generated og-image */
+@source not "../pages/og-image";
+/* config for tailwindcss-typography plugin */
+@config "../../tailwind.config.ts";
+
+/* use a selector-based strategy for dark mode */
+@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
+
+/* register custom colour properties */
+@property --color-global-bg {
+ syntax: "";
+ inherits: true;
+ initial-value: oklch(98.48% 0 0);
+}
+@property --color-global-text {
+ syntax: "";
+ inherits: true;
+ initial-value: oklch(26.99% 0.0096 235.05);
+}
+@property --color-muted {
+ syntax: "";
+ inherits: true;
+ initial-value: oklch(44.6% 0.03 256.802);
+}
+@property --color-link {
+ syntax: "";
+ inherits: true;
+ initial-value: oklch(55.44% 0.0431 185.69);
+}
+@property --color-accent {
+ syntax: "";
+ inherits: true;
+ initial-value: oklch(55.27% 0.195 19.06);
+}
+@property --color-accent-2 {
+ syntax: "";
+ inherits: true;
+ initial-value: oklch(18.15% 0 0);
+}
+@property --color-quote {
+ syntax: "";
+ inherits: true;
+ initial-value: oklch(55.27% 0.195 19.06);
+}
+
+/* you could refactor below to use light-dark(), depending on your target audience */
+@theme {
+ --color-global-bg: oklch(98.48% 0 0);
+ --color-global-text: oklch(26.99% 0.0096 235.05);
+ --color-muted: oklch(44.6% 0.03 256.802);
+ --color-link: oklch(55.44% 0.0431 185.69);
+ --color-accent: oklch(55.27% 0.195 19.06);
+ --color-accent-2: oklch(18.15% 0 0);
+ --color-quote: oklch(55.27% 0.195 19.06);
+}
+
+@layer base {
+ html {
+ color-scheme: light dark;
+ accent-color: var(--color-accent);
+ scrollbar-gutter: stable;
+
+ &[data-theme="light"] {
+ color-scheme: light;
+ }
+
+ &[data-theme="dark"] {
+ color-scheme: dark;
+ --color-global-bg: oklch(23.64% 0.0045 248);
+ --color-global-text: oklch(83.54% 0 264);
+ --color-muted: oklch(70.7% 0.022 261.325);
+ --color-link: oklch(70.44% 0.1133 349);
+ --color-accent: oklch(70.91% 0.1415 163.7);
+ --color-accent-2: oklch(94.66% 0 0);
+ --color-quote: oklch(94.8% 0.106 136.49);
+ }
+
+ --color-mode-transition-duration: 300ms;
+ --color-mode-transition-easing: ease-in-out;
+
+ transition:
+ --color-global-bg var(--color-mode-transition-duration) var(--color-mode-transition-easing),
+ --color-global-text var(--color-mode-transition-duration) var(--color-mode-transition-easing),
+ --color-muted var(--color-mode-transition-duration) var(--color-mode-transition-easing),
+ --color-link var(--color-mode-transition-duration) var(--color-mode-transition-easing),
+ --color-accent var(--color-mode-transition-duration) var(--color-mode-transition-easing),
+ --color-accent-2 var(--color-mode-transition-duration) var(--color-mode-transition-easing),
+ --color-quote var(--color-mode-transition-duration) var(--color-mode-transition-easing);
+ }
+
+ :target {
+ scroll-margin-block: 5ex;
+ }
+
+ @view-transition {
+ navigation: auto;
+ }
+
+ /* Astro image responsive styles, modified from -> https://docs.astro.build/en/guides/images/#responsive-image-styles */
+ :where([data-astro-image]) {
+ object-fit: var(--fit);
+ object-position: var(--pos);
+ }
+ [data-astro-image="full-width"] {
+ width: 100%;
+ }
+ [data-astro-image="constrained"] {
+ max-width: 100%;
+ }
+}
+
+@layer components {
+ @import "./components/admonition.css";
+ @import "./components/github-card.css";
+
+ .cactus-link {
+ @apply hover:decoration-link underline underline-offset-2 hover:decoration-2;
+ }
+
+ .title {
+ @apply text-accent-2 text-2xl font-semibold;
+ }
+}
+
+@utility prose {
+ --tw-prose-body: var(--color-global-text);
+ --tw-prose-bold: var(--color-global-text);
+ --tw-prose-bullets: var(--color-global-text);
+ --tw-prose-code: var(--color-global-text);
+ --tw-prose-headings: var(--color-accent-2);
+ --tw-prose-hr: 0.5px dashed #666;
+ --tw-prose-links: var(--color-global-text);
+ --tw-prose-quotes: var(--color-quote);
+ --tw-prose-th-borders: #666;
+}
diff --git a/src/styles/prism.css b/src/styles/prism.css
deleted file mode 100644
index 2535c6d..0000000
--- a/src/styles/prism.css
+++ /dev/null
@@ -1,47 +0,0 @@
-pre[class*='language-'] {
- color: theme('colors.zinc.100');
-}
-
-.token.tag,
-.token.class-name,
-.token.selector,
-.token.selector .class,
-.token.selector.class,
-.token.function {
- color: theme('colors.pink.400');
-}
-
-.token.attr-name,
-.token.keyword,
-.token.rule,
-.token.pseudo-class,
-.token.important {
- color: theme('colors.zinc.300');
-}
-
-.token.module {
- color: theme('colors.pink.400');
-}
-
-.token.attr-value,
-.token.class,
-.token.string,
-.token.property {
- color: theme('colors.teal.300');
-}
-
-.token.punctuation,
-.token.attr-equals {
- color: theme('colors.zinc.500');
-}
-
-.token.unit,
-.language-css .token.function {
- color: theme('colors.sky.200');
-}
-
-.token.comment,
-.token.operator,
-.token.combinator {
- color: theme('colors.zinc.400');
-}
diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css
deleted file mode 100644
index ae59433..0000000
--- a/src/styles/tailwind.css
+++ /dev/null
@@ -1,4 +0,0 @@
-@import 'tailwindcss/base';
-@import 'tailwindcss/components';
-@import './prism.css';
-@import 'tailwindcss/utilities';