Replace Astro/Strapi site with Next.js static export

- Fresh structure: Next.js App Router + Tailwind v4 at the repo root, exported
  as static files (output: export) and served by static-web-server
- Remove the Strapi CMS, Astro app, Pagefind search, comments, old scripts,
  Lighthouse config and compose files; content in content/ is now plain
  markdown with YAML frontmatter
- Pages: About, Writing, Reading (3D bookshelf) and Portfolio, styled after
  adammaj.com
- SEO: metadata and canonicals, JSON-LD, generated OG images, sitemap, robots,
  RSS and JSON feeds, llms.txt
- Umami via PUBLIC_UMAMI_SCRIPT_URL / PUBLIC_UMAMI_WEBSITE_ID (build args)
- Dockerfile builds the export and serves it with sws.toml (security headers,
  clean URLs, .well-known, OG image content type, immutable asset caching)
- Prettier and cspell for content with a husky/lint-staged pre-commit hook
- Old redirect list dropped to start from a clean slate
This commit is contained in:
2026-09-30 12:48:14 +03:00
parent cafcb5b4e0
commit ec6401ac38
227 changed files with 4357 additions and 27144 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+29
View File
@@ -0,0 +1,29 @@
import { getFeedItems } from "@/lib/feed";
import { abs, site } from "@/lib/site";
export const dynamic = "force-static";
export async function GET() {
const items = await getFeedItems();
const feed = {
version: "https://jsonfeed.org/version/1.1",
title: site.name,
home_page_url: site.baseUrl,
feed_url: abs("/feed.json"),
description: site.description,
language: "en",
authors: [{ name: site.name, url: site.baseUrl }],
items: items.map((i) => ({
id: i.url,
url: i.url,
title: i.title,
summary: i.description,
content_html: i.html,
date_published: i.date.toISOString(),
tags: i.tags,
})),
};
return new Response(JSON.stringify(feed), {
headers: { "Content-Type": "application/feed+json; charset=utf-8" },
});
}
+111
View File
@@ -0,0 +1,111 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--breakpoint-sm: 30rem;
--breakpoint-lg: 62rem;
--font-sans: var(--font-lora), ui-serif, Georgia, serif;
--color-gray-100: #edf2f7;
--color-gray-200: #e2e8f0;
--color-gray-400: #a0aec0;
--color-gray-500: #718096;
--color-gray-600: #4a5568;
--color-gray-800: #1a202c;
--color-blue-500: #3182ce;
--color-blue-600: #2b6cb0;
}
body {
background: #fff;
color: var(--color-gray-800);
font-family: var(--font-sans);
line-height: 1.5;
}
/* Prose overrides mirroring adammaj.com's chakra-ui-prose config */
hr {
width: 100%;
height: 0;
border: 0;
border-bottom: 1px solid var(--color-gray-200);
opacity: 0.6;
}
.prose {
line-height: 1.5;
--tw-prose-body: var(--color-gray-800);
--tw-prose-headings: var(--color-gray-800);
--tw-prose-links: var(--color-blue-500);
max-width: none;
}
.prose :where(h1, h2, h3, h4):not(:where(.not-prose *)) {
margin-top: 1rem;
margin-bottom: 1rem;
}
.prose :where(p):not(:where(.not-prose *)) {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
.prose :where(a):not(:where(.not-prose *)) {
color: var(--color-blue-500);
font-weight: inherit;
text-decoration: underline;
}
.prose :where(pre):not(:where(.not-prose *)) {
background: #f6f8fa;
color: var(--color-gray-800);
border: 1px solid #e1e4e8;
}
.prose :where(code):not(:where(pre *)):not(:where(.not-prose *))::before,
.prose :where(code):not(:where(pre *)):not(:where(.not-prose *))::after {
content: none;
}
.callout {
margin: 1rem 0;
padding: 0.25rem 1rem;
border-left: 4px solid var(--color-blue-500);
border-radius: 0 0.375rem 0.375rem 0;
background: rgb(49 130 206 / 0.05);
}
.callout-tip {
border-color: #10b981;
background: rgb(16 185 129 / 0.05);
}
.callout-warning {
border-color: #f59e0b;
background: rgb(245 158 11 / 0.05);
}
.callout-danger {
border-color: #ef4444;
background: rgb(239 68 68 / 0.05);
}
.youtube {
position: relative;
margin: 1rem 0;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 0.375rem;
}
.youtube iframe {
width: 100%;
height: 100%;
border: 0;
}
/* Shiki (rehype-pretty-code): theme colours are inline; keep block chrome from .prose pre */
pre[data-language] {
overflow-x: auto;
padding: 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
line-height: 1.6;
}
pre[data-language] code {
background: none;
padding: 0;
font-size: inherit;
}
+90
View File
@@ -0,0 +1,90 @@
import type { Metadata, Viewport } from "next";
import { Lora } from "next/font/google";
import Script from "next/script";
import { JsonLd } from "@/components/JsonLd";
import { Layout } from "@/components/Layout";
import { site } from "@/lib/site";
import "./globals.css";
import "katex/dist/katex.min.css";
const lora = Lora({
variable: "--font-lora",
subsets: ["latin"],
display: "swap",
});
export const viewport: Viewport = { themeColor: "#ffffff" };
export const metadata: Metadata = {
metadataBase: new URL(site.baseUrl),
title: { default: site.title, template: `%s | ${site.name}` },
description: site.description,
keywords: site.keywords,
authors: [{ name: site.name, url: site.baseUrl }],
creator: site.name,
publisher: site.name,
openGraph: {
siteName: site.name,
type: "website",
locale: site.locale,
url: "/",
title: site.title,
description: site.description,
},
twitter: {
card: "summary_large_image",
site: site.twitter,
creator: site.twitter,
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
const websiteLd = {
"@context": "https://schema.org",
"@type": "WebSite",
name: site.name,
alternateName: site.tagline,
url: site.baseUrl,
inLanguage: "en",
author: { "@type": "Person", name: site.name },
};
const umamiScript = process.env.PUBLIC_UMAMI_SCRIPT_URL;
const umamiId = process.env.PUBLIC_UMAMI_WEBSITE_ID;
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" className={lora.variable}>
<head>
<link
rel="alternate"
type="application/rss+xml"
title={`${site.name} RSS`}
href="/rss.xml"
/>
<link
rel="alternate"
type="application/feed+json"
title={`${site.name} JSON Feed`}
href="/feed.json"
/>
</head>
<body>
<JsonLd data={websiteLd} />
<Layout>{children}</Layout>
{umamiScript && umamiId && (
<Script defer src={umamiScript} data-website-id={umamiId} />
)}
</body>
</html>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { getBooks, getWritings } from "@/lib/content";
import { abs, site } from "@/lib/site";
export const dynamic = "force-static";
export function GET() {
const body = `# ${site.name}
> ${site.description}
${site.name} (also known as ${site.alias}) is a software engineer and the captain of the binarysith CTF team. This site collects his original essays, CTF writeups, reading list and projects.
## Writing
${getWritings()
.map(
(p) =>
`- [${p.data.title}](${abs(`/writing/${p.slug}`)})${p.data.description ? `: ${p.data.description}` : ""}`,
)
.join("\n")}
## Reading
${getBooks()
.map((b) => `- [${b.data.title}](${abs(`/reading/${b.slug}`)})`)
.join("\n")}
## Other
- [About](${abs("/")})
- [Portfolio](${abs("/portfolio")})
- [RSS](${abs("/rss.xml")})
- [Sitemap](${abs("/sitemap.xml")})
`;
return new Response(body, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function NotFound() {
redirect("/");
}
+11
View File
@@ -0,0 +1,11 @@
import { ogContentType, ogImage, ogSize } from "@/lib/og";
import { site } from "@/lib/site";
export const alt = site.title;
export const size = ogSize;
export const dynamic = "force-static";
export const contentType = ogContentType;
export default function Image() {
return ogImage(site.name, "Software engineer · CTF player");
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { JsonLd } from "@/components/JsonLd";
import { Mdx } from "@/components/Mdx";
import { getAbout } from "@/lib/content";
import { site } from "@/lib/site";
export const metadata: Metadata = {
alternates: { canonical: "/" },
};
const personLd = {
"@context": "https://schema.org",
"@type": "Person",
name: site.name,
alternateName: site.alias,
url: site.baseUrl,
jobTitle: "Software Engineer",
description: site.description,
sameAs: site.sameAs,
knowsAbout: [
"Cyber security",
"Capture the flag",
"Binary exploitation",
"Reverse engineering",
"Low-level programming",
"Distributed systems",
"Compiler design",
"Database internals",
],
memberOf: {
"@type": "Organization",
name: "binarysith",
url: "https://ctftime.org/team/379200",
},
};
export default function Home() {
return (
<div className="prose">
<JsonLd data={personLd} />
<Mdx source={getAbout()} />
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { Mdx } from "@/components/Mdx";
import { getPortfolio } from "@/lib/content";
export const metadata: Metadata = {
title: "Portfolio",
description: "Projects and infrastructure-level software by Ian Andwati.",
alternates: { canonical: "/portfolio" },
};
export default function PortfolioPage() {
const items = getPortfolio();
return (
<div className="flex flex-col gap-4">
<h1 className="sr-only">Portfolio</h1>
<hr />
{items.map((item) => (
<div key={item.slug} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="font-bold">{item.data.title}</h2>
<p className="pb-4 text-sm text-gray-600">
{item.data.description}
</p>
<div className="prose text-sm text-gray-600">
<Mdx source={item.content} />
</div>
<div className="flex gap-6 pt-2">
{item.data.links?.map((l) => (
<a
key={l.url}
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
{l.label}
</a>
))}
</div>
</div>
<hr />
</div>
))}
</div>
);
}
@@ -0,0 +1,20 @@
import { getBooks } from "@/lib/content";
import { ogContentType, ogImage, ogSize } from "@/lib/og";
export const size = ogSize;
export const dynamic = "force-static";
export const contentType = ogContentType;
export function generateStaticParams() {
return getBooks().map((b) => ({ slug: b.slug }));
}
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const book = getBooks().find((b) => b.slug === slug);
return ogImage(book?.data.title ?? "Reading", "Reading");
}
+95
View File
@@ -0,0 +1,95 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { breadcrumbs, JsonLd } from "@/components/JsonLd";
import { Mdx } from "@/components/Mdx";
import { byline } from "@/lib/books";
import { getBooks } from "@/lib/content";
import { abs, site } from "@/lib/site";
export function generateStaticParams() {
return getBooks().map((b) => ({ slug: b.slug }));
}
export async function generateMetadata({
params,
}: PageProps<"/reading/[slug]">): Promise<Metadata> {
const { slug } = await params;
const book = getBooks().find((b) => b.slug === slug);
if (!book) return {};
const url = `/reading/${slug}`;
const description = byline(book.data) || site.description;
return {
title: book.data.title,
description,
keywords: book.data.tags,
alternates: { canonical: url },
openGraph: {
type: "article",
url,
title: book.data.title,
description,
siteName: site.name,
locale: site.locale,
},
twitter: {
card: "summary_large_image",
title: book.data.title,
description,
},
};
}
export default async function Book({ params }: PageProps<"/reading/[slug]">) {
const { slug } = await params;
const book = getBooks().find((b) => b.slug === slug);
if (!book) notFound();
const url = abs(`/reading/${slug}`);
const item = {
"@type": book.data.kind === "paper" ? "ScholarlyArticle" : "Book",
name: book.data.title,
url: book.data.url,
author: book.data.authors?.map((name) => ({ "@type": "Person", name })),
};
const reviewLd = book.data.rating
? {
"@context": "https://schema.org",
"@type": "Review",
url,
name: `Review: ${book.data.title}`,
datePublished: book.data.dateFinished,
author: { "@type": "Person", name: site.name, url: site.baseUrl },
itemReviewed: item,
reviewRating: {
"@type": "Rating",
ratingValue: book.data.rating,
bestRating: 5,
worstRating: 1,
},
}
: null;
return (
<article className="flex flex-col gap-2">
<JsonLd
data={[
...(reviewLd ? [reviewLd] : []),
breadcrumbs([
{ name: "Home", url: site.baseUrl },
{ name: "Reading", url: abs("/reading") },
{ name: book.data.title, url },
]),
]}
/>
<div className="flex flex-col">
<h1 className="text-[36px] leading-[1.2] font-bold">
{book.data.title}
</h1>
<p className="mt-2 text-[20px] leading-[30px] text-gray-400">
{byline(book.data)}
</p>
</div>
<div className="prose">
<Mdx source={book.content} />
</div>
</article>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Bookshelf } from "@/components/Bookshelf";
import { getShelf } from "@/lib/books";
export default function ReadingLayout({ children }: LayoutProps<"/reading">) {
return (
<div className="flex flex-col gap-8">
<Bookshelf books={getShelf()} />
<hr />
{children}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Mdx } from "@/components/Mdx";
import { readLine } from "@/lib/books";
import { coverExists, getBooks, spineFor, summarize } from "@/lib/content";
export const metadata: Metadata = {
title: "Reading",
description: "Books and papers I have read, with ratings and notes.",
alternates: { canonical: "/reading" },
};
export default function ReadingIndex() {
return (
<div className="flex flex-col">
<h1 className="sr-only">Reading</h1>
{getBooks().map((b, i) => {
const cover = coverExists(b.data.coverImage)
? b.data.coverImage
: undefined;
const { spineColor, textColor } = spineFor(b.data.title);
const summary = summarize(b.content);
return (
<div key={b.slug} className={i > 0 ? "mt-5" : undefined}>
{i > 0 && <hr className="mb-3" />}
<div className="flex gap-6">
{cover ? (
// biome-ignore lint/performance/noImgElement: small local cover
<img
src={cover}
alt={b.data.title}
className="h-[100px] border border-gray-200 sm:h-[140px] md:h-[160px]"
style={{ width: "auto" }}
/>
) : (
<div
className="flex h-[100px] w-[68px] shrink-0 items-center border border-gray-200 p-2 text-xs font-bold sm:h-[140px] sm:w-24 md:h-[160px] md:w-28"
style={{ backgroundColor: spineColor, color: textColor }}
>
{b.data.title}
</div>
)}
<div className="flex min-w-0 flex-col">
<Link
href={`/reading/${b.slug}`}
className="block hover:underline"
>
<h2 className="text-[20px] leading-6 font-bold">
{b.data.title}
</h2>
</Link>
{b.data.authors?.length ? (
<p className="mt-2 text-[#999]">
{b.data.authors.join(", ")}
</p>
) : null}
<p className="mt-2 text-[#666]">{readLine(b.data)}</p>
{summary && (
<article className="prose mt-2">
<Mdx source={summary} />
</article>
)}
</div>
</div>
</div>
);
})}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
import { abs, site } from "@/lib/site";
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: "*", allow: "/" }],
sitemap: abs("/sitemap.xml"),
host: site.baseUrl,
};
}
+36
View File
@@ -0,0 +1,36 @@
import { getFeedItems, xml } from "@/lib/feed";
import { abs, site } from "@/lib/site";
export const dynamic = "force-static";
export async function GET() {
const items = await getFeedItems();
const body = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${xml(site.name)}</title>
<link>${site.baseUrl}</link>
<description>${xml(site.description)}</description>
<language>en</language>
<lastBuildDate>${(items[0]?.date ?? new Date()).toUTCString()}</lastBuildDate>
<atom:link href="${abs("/rss.xml")}" rel="self" type="application/rss+xml"/>
${items
.map(
(i) => `<item>
<title>${xml(i.title)}</title>
<link>${i.url}</link>
<guid isPermaLink="true">${i.url}</guid>
<pubDate>${i.date.toUTCString()}</pubDate>
<description>${xml(i.description)}</description>
${i.tags.map((t) => `<category>${xml(t)}</category>`).join("\n")}
<content:encoded><![CDATA[${i.html.replace(/]]>/g, "]]]]><![CDATA[>")}]]></content:encoded>
</item>`,
)
.join("\n")}
</channel>
</rss>
`;
return new Response(body, {
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
});
}
+35
View File
@@ -0,0 +1,35 @@
import type { MetadataRoute } from "next";
import { getBooks, getWritings } from "@/lib/content";
import { abs } from "@/lib/site";
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getWritings();
const latest = posts[0]?.data.date;
return [
{ url: abs("/"), changeFrequency: "monthly", priority: 1 },
{
url: abs("/writing"),
lastModified: latest ? new Date(String(latest)) : undefined,
changeFrequency: "weekly",
priority: 0.9,
},
{ url: abs("/reading"), changeFrequency: "monthly", priority: 0.6 },
{ url: abs("/portfolio"), changeFrequency: "monthly", priority: 0.6 },
...posts.map((p) => ({
url: abs(`/writing/${p.slug}`),
lastModified: new Date(String(p.data.date)),
changeFrequency: "yearly" as const,
priority: 0.8,
})),
...getBooks().map((b) => ({
url: abs(`/reading/${b.slug}`),
lastModified: b.data.dateFinished
? new Date(b.data.dateFinished)
: undefined,
changeFrequency: "yearly" as const,
priority: 0.4,
})),
];
}
@@ -0,0 +1,20 @@
import { getWritings } from "@/lib/content";
import { ogContentType, ogImage, ogSize } from "@/lib/og";
export const size = ogSize;
export const dynamic = "force-static";
export const contentType = ogContentType;
export function generateStaticParams() {
return getWritings().map((p) => ({ slug: p.slug }));
}
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = getWritings().find((p) => p.slug === slug);
return ogImage(post?.data.title ?? "Writing", "Writing");
}
+86
View File
@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { breadcrumbs, JsonLd } from "@/components/JsonLd";
import { Mdx } from "@/components/Mdx";
import { formatDate, getWritings } from "@/lib/content";
import { abs, site } from "@/lib/site";
export function generateStaticParams() {
return getWritings().map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({
params,
}: PageProps<"/writing/[slug]">): Promise<Metadata> {
const { slug } = await params;
const post = getWritings().find((p) => p.slug === slug);
if (!post) return {};
const url = `/writing/${slug}`;
const description = post.data.description || site.description;
return {
title: post.data.title,
description,
keywords: post.data.tags,
alternates: { canonical: url },
openGraph: {
type: "article",
url,
title: post.data.title,
description,
siteName: site.name,
locale: site.locale,
publishedTime: String(post.data.date),
modifiedTime: String(post.data.date),
authors: [site.name],
tags: post.data.tags,
},
twitter: {
card: "summary_large_image",
title: post.data.title,
description,
},
};
}
export default async function Post({ params }: PageProps<"/writing/[slug]">) {
const { slug } = await params;
const post = getWritings().find((p) => p.slug === slug);
if (!post) notFound();
const url = abs(`/writing/${slug}`);
const postLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.data.title,
description: post.data.description || site.description,
datePublished: String(post.data.date),
dateModified: String(post.data.date),
inLanguage: "en",
keywords: post.data.tags?.join(", "),
url,
mainEntityOfPage: { "@type": "WebPage", "@id": url },
image: abs(`/writing/${slug}/opengraph-image`),
author: { "@type": "Person", name: site.name, url: site.baseUrl },
publisher: { "@type": "Person", name: site.name, url: site.baseUrl },
};
return (
<article className="flex flex-col gap-2">
<JsonLd
data={[
postLd,
breadcrumbs([
{ name: "Home", url: site.baseUrl },
{ name: "Writing", url: abs("/writing") },
{ name: post.data.title, url },
]),
]}
/>
<h1 className="text-3xl font-bold md:text-4xl">{post.data.title}</h1>
<time dateTime={String(post.data.date)} className="text-sm text-gray-500">
{formatDate(String(post.data.date))}
</time>
<div className="prose">
<Mdx source={post.content} />
</div>
</article>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import Link from "next/link";
import { formatDate, getWritings } from "@/lib/content";
export const metadata: Metadata = {
title: "Writing",
description:
"Essays and CTF writeups on software development, security, binary exploitation and Linux.",
alternates: { canonical: "/writing" },
};
export default function WritingIndex() {
const posts = getWritings();
return (
<div className="flex flex-col gap-3">
<h1 className="sr-only">Writing</h1>
<hr />
{posts.map((post) => (
<div key={post.slug} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<Link
href={`/writing/${post.slug}`}
className="text-blue-600 hover:underline"
>
{post.data.title}
</Link>
<span className="text-right text-sm text-gray-500">
{formatDate(String(post.data.date))}
</span>
</div>
<hr />
</div>
))}
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import type { ShelfBook } from "@/lib/books";
const W = 41.5;
const H = 220;
const SPINE = `${W}px`;
const COVER = `${W * 4}px`;
const BOOK = `${W * 5}px`;
const FILTER = "brightness(0.8) contrast(2)";
const PAPER = {
pointerEvents: "none",
opacity: 0.4,
filter: "url(#paper)",
zIndex: 50,
} as const;
export function Bookshelf({ books }: { books: ShelfBook[] }) {
const router = useRouter();
const pathname = usePathname();
const slug = pathname.split("/")[2];
const bookIndex = slug ? books.findIndex((b) => b.slug === slug) : -1;
const viewportRef = useRef<HTMLDivElement>(null);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const [scroll, setScroll] = useState(0);
const [inView, setInView] = useState(0);
const [isScrolling, setIsScrolling] = useState(false);
const maxScroll =
(W + 12) * (books.length - inView) + (bookIndex > -1 ? W * 4 : 0) + 5;
const clamp = (x: number) => Math.max(0, Math.min(maxScroll, x));
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const measure = () => setInView(el.clientWidth / (W + 11));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
if (bookIndex === -1) setScroll((s) => Math.max(0, s));
else setScroll(Math.max(0, (bookIndex - (inView - 4.5) / 2) * (W + 11)));
}, [bookIndex, inView]);
useEffect(
() => () => {
if (timer.current) clearInterval(timer.current);
},
[],
);
const start = (dir: 1 | -1) => {
if (timer.current) clearInterval(timer.current);
setIsScrolling(true);
timer.current = setInterval(() => setScroll((s) => clamp(s + dir * 3)), 10);
};
const stop = () => {
setIsScrolling(false);
if (timer.current) clearInterval(timer.current);
timer.current = null;
};
const arrow = (dir: 1 | -1) => ({
onMouseEnter: () => start(dir),
onMouseLeave: stop,
onTouchStart: () => start(dir),
onTouchEnd: stop,
});
return (
<>
<svg className="invisible absolute inset-0" aria-hidden="true">
<defs>
<filter id="paper" x="0%" y="0%" width="100%" height="100%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.9"
numOctaves="8"
result="noise"
/>
<feDiffuseLighting
in="noise"
lightingColor="white"
surfaceScale="1"
result="diffLight"
>
<feDistantLight azimuth="45" elevation="35" />
</feDiffuseLighting>
</filter>
</defs>
</svg>
<div className="relative">
<div
className={`absolute -left-7 h-full md:-left-9 ${scroll > 0 ? "block" : "hidden"}`}
>
<div
{...arrow(-1)}
className="flex h-full w-7 items-center justify-center rounded-md max-md:rounded-r-none hover:bg-gray-100"
>
<Chevron dir="left" />
</div>
</div>
<div
ref={viewportRef}
className="flex cursor-grab items-center gap-3 overflow-x-hidden"
>
{books.map((book, i) => {
const open = bookIndex === i;
return (
<button
type="button"
key={book.slug}
onClick={() => router.push(open ? "/reading" : book.href)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
outline: "none",
flexShrink: 0,
transform: `translateX(-${scroll}px)`,
width: open ? BOOK : SPINE,
perspective: "1000px",
transition: isScrolling
? "transform 100ms linear"
: "all 500ms ease",
}}
>
<div
className="flex shrink-0 items-start justify-center"
style={{
width: SPINE,
height: H,
transformOrigin: "right",
backgroundColor: book.spineColor,
color: book.textColor,
transform: `rotateY(${open ? "-60deg" : "0deg"})`,
transition: "all 500ms ease",
filter: FILTER,
transformStyle: "preserve-3d",
}}
>
<span
className="fixed top-0 left-0"
style={{ ...PAPER, height: H, width: SPINE }}
/>
<span
className="mt-3 block select-none overflow-hidden text-ellipsis whitespace-nowrap text-[12px] leading-[1.2] font-bold"
style={{
writingMode: "vertical-rl",
maxHeight: H - 24,
fontFamily: '"DM Sans", sans-serif',
}}
>
{book.title}
</span>
</div>
<div
className="relative shrink-0 overflow-hidden"
style={{
transformOrigin: "left",
transform: `rotateY(${open ? "30deg" : "88.8deg"})`,
transition: "all 500ms ease",
filter: FILTER,
transformStyle: "preserve-3d",
}}
>
<span
className="fixed top-0 right-0"
style={{ ...PAPER, height: H, width: COVER }}
/>
<span
className="absolute top-0 left-0"
style={{
pointerEvents: "none",
zIndex: 50,
height: H,
width: COVER,
background:
"linear-gradient(to right, rgba(255,255,255,0) 2px, rgba(255,255,255,0.5) 3px, rgba(255,255,255,0.25) 4px, rgba(255,255,255,0.25) 6px, transparent 7px, transparent 9px, rgba(255,255,255,0.25) 9px, transparent 12px)",
}}
/>
{book.coverImage ? (
// biome-ignore lint/performance/noImgElement: fixed-size local cover inside a 3D transform
<img
src={book.coverImage}
alt={book.title}
width={W * 4}
height={H}
style={{ width: COVER, height: H }}
/>
) : (
<div
className="flex items-center p-4 text-left text-sm font-bold"
style={{
width: COVER,
height: H,
backgroundColor: book.spineColor,
color: book.textColor,
}}
>
{book.title}
</div>
)}
</div>
</button>
);
})}
</div>
<div
className={`absolute top-0 -right-7 h-full pl-2.5 md:-right-9 ${scroll < maxScroll ? "block" : "hidden"}`}
>
<div
{...arrow(1)}
className="flex h-full w-7 items-center justify-center rounded-md max-md:rounded-l-none hover:bg-gray-100"
>
<Chevron dir="right" />
</div>
</div>
</div>
</>
);
}
function Chevron({ dir }: { dir: "left" | "right" }) {
return (
<svg
viewBox="0 0 24 24"
className="size-3"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={dir === "left" ? "M15 4l-8 8 8 8" : "M9 4l8 8-8 8"} />
</svg>
);
}
+29
View File
@@ -0,0 +1,29 @@
/** Renders a JSON-LD block. `<` is escaped so content can't close the script tag. */
export function JsonLd({
data,
}: {
data: Record<string, unknown> | Record<string, unknown>[];
}) {
return (
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD must be inlined; `<` is escaped
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
);
}
export function breadcrumbs(items: { name: string; url: string }[]) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, i) => ({
"@type": "ListItem",
position: i + 1,
name: item.name,
item: item.url,
})),
};
}
+15
View File
@@ -0,0 +1,15 @@
import { MobileNav } from "@/components/MobileNav";
import { Sidebar } from "@/components/Sidebar";
// Mirrors adammaj.com: a 596px wrapper (Chakra container) centred on the page, with a
// second, equally wide container inside it whose 16px padding offsets the text column.
export function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="relative mx-auto mt-16 w-full max-w-[596.161px] px-4 pb-8 md:pb-40">
<div className="fixed inset-x-0 top-0 z-100 hidden h-16 bg-white lg:block" />
<MobileNav />
<Sidebar />
<main className="relative px-4 md:w-[596.161px]">{children}</main>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { MDXRemote } from "next-mdx-remote/rsc";
import rehypeKatex from "rehype-katex";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
/** Renders exported markdown: GFM, raw HTML (callouts, embeds), and KaTeX math. */
export function Mdx({ source }: { source: string }) {
return (
<MDXRemote
source={source}
options={{
mdxOptions: {
format: "md",
remarkPlugins: [remarkGfm, remarkMath],
rehypePlugins: [
rehypeRaw,
rehypeKatex,
[
rehypePrettyCode,
{
theme: "github-light",
keepBackground: false,
defaultLanguage: "txt",
},
],
],
},
}}
/>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { NavLink } from "@/components/NavLink";
import { site } from "@/lib/site";
export function MobileNav() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const pathname = usePathname();
// biome-ignore lint/correctness/useExhaustiveDependencies: close the menu when the route changes
useEffect(() => setOpen(false), [pathname]);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return (
<header className="fixed inset-x-0 top-0 z-50 h-12 border-b border-gray-200 bg-white lg:hidden">
<div className="mx-auto flex h-full max-w-5xl items-center justify-between px-8">
<div className="flex gap-8">
{site.nav.slice(0, 3).map((item) => (
<NavLink key={item.href} href={item.href}>
{item.name}
</NavLink>
))}
</div>
<div ref={ref} className="relative">
<button
type="button"
aria-label="Menu"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 hover:bg-gray-100"
>
<svg
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-10 flex w-44 flex-col gap-4 rounded-md border border-gray-200 bg-white py-4 shadow-lg">
<div className="flex flex-col gap-3 px-4">
<h2 className="text-xs font-bold">NAVIGATION</h2>
{site.nav.map((item) => (
<NavLink key={item.href} href={item.href}>
{item.name}
</NavLink>
))}
</div>
<div className="flex flex-col gap-3 px-4">
<h2 className="text-xs font-bold">FIND ME ON</h2>
{site.social.map((item) => (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-black"
>
{item.name}
</a>
))}
</div>
</div>
)}
</div>
</div>
</header>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function NavLink({
href,
className = "",
children,
}: {
href: string;
className?: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
href={href}
className={`${active ? "text-black" : "text-[#718096]"} hover:text-black ${className}`}
>
{children}
</Link>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { NavLink } from "@/components/NavLink";
import { site } from "@/lib/site";
const link = "mt-2 block w-fit text-[18px] leading-[27px]";
export function Sidebar() {
return (
<aside className="absolute right-full mr-40 hidden lg:flex">
<div className="fixed flex flex-col items-start whitespace-nowrap">
<nav>
<p className="text-[13.3333px] leading-5 font-bold">NAVIGATION</p>
{site.nav.map((item) => (
<NavLink key={item.href} href={item.href} className={link}>
{item.name}
</NavLink>
))}
</nav>
<div className="mt-10">
<p className="text-[13.3333px] leading-5 font-bold">FIND ME ON</p>
{site.social.map((item) => (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className={`${link} text-gray-500 hover:text-black`}
>
{item.name}
</a>
))}
</div>
</div>
</aside>
);
}
+44
View File
@@ -0,0 +1,44 @@
import {
type Book,
coverExists,
formatDate,
getBooks,
spineFor,
} from "@/lib/content";
export type ShelfBook = {
slug: string;
href: string;
title: string;
coverImage?: string;
spineColor: string;
textColor: string;
};
export function getShelf(): ShelfBook[] {
return getBooks().map((b) => ({
slug: b.slug,
href: `/reading/${b.slug}`,
title: b.data.title,
coverImage: coverExists(b.data.coverImage) ? b.data.coverImage : undefined,
...spineFor(b.data.title),
}));
}
export function readLine(b: Book) {
return [
b.dateFinished && `Read: ${formatDate(b.dateFinished)}`,
b.rating && `Rating: ${b.rating}/5`,
]
.filter(Boolean)
.join(" • ");
}
export function byline(b: Book) {
const parts = [
b.authors?.length && `By: ${b.authors.join(", ")}`,
b.dateFinished && `Read: ${formatDate(b.dateFinished)}`,
b.rating && `Rating: ${b.rating}/5`,
];
return parts.filter(Boolean).join(" - ");
}
+118
View File
@@ -0,0 +1,118 @@
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
const ROOT = path.join(process.cwd(), "content");
export type Entry<T> = { slug: string; data: T; content: string };
export type Writing = {
title: string;
description?: string;
date: string;
tags?: string[];
};
export type Portfolio = {
title: string;
description?: string;
role?: string;
dateStart?: string;
dateEnd?: string;
outcome?: string;
links?: { label: string; url: string }[];
};
export type Book = {
title: string;
kind: "book" | "paper";
authors?: string[];
url?: string;
coverImage?: string;
rating?: number;
status?: string;
dateFinished?: string;
tags?: string[];
};
function readDir<T>(dir: string): Entry<T>[] {
const full = path.join(ROOT, dir);
if (!fs.existsSync(full)) return [];
return fs
.readdirSync(full)
.filter((f) => f.endsWith(".md"))
.map((f) => {
const { data, content } = matter(
fs.readFileSync(path.join(full, f), "utf8"),
);
return { slug: f.replace(/\.md$/, ""), data: data as T, content };
});
}
export function getWritings() {
return readDir<Writing>("writing").sort((a, b) =>
String(b.data.date).localeCompare(String(a.data.date)),
);
}
export function getPortfolio() {
return readDir<Portfolio>("portfolio").sort((a, b) =>
String(b.data.dateStart ?? "").localeCompare(
String(a.data.dateStart ?? ""),
),
);
}
export function getBooks() {
return readDir<Book>("reading").sort(
(a, b) =>
(b.data.rating ?? 0) - (a.data.rating ?? 0) ||
a.data.title.localeCompare(b.data.title),
);
}
export function getAbout() {
const { content } = matter(
fs.readFileSync(path.join(ROOT, "about.md"), "utf8"),
);
return content;
}
/** The part of a review shown on the index: everything before "## My Notes". */
export function summarize(content: string) {
return content.split(/^## My Notes/m)[0].trim();
}
export function formatDate(date: string) {
return new Date(`${date}T00:00:00Z`).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
const SPINES: [string, string][] = [
["#7c3a2d", "#fbe9d0"],
["#264653", "#e9f5f2"],
["#2a4d3a", "#f1f7e8"],
["#5b3a6b", "#f6ecfa"],
["#c9a227", "#2b2110"],
["#1d3557", "#f1faee"],
["#8d5a3b", "#fff4e6"],
["#3b3b3b", "#f0f0f0"],
];
export function spineFor(title: string) {
let h = 0;
for (const c of title) h = (h * 31 + c.charCodeAt(0)) >>> 0;
const [spineColor, textColor] = SPINES[h % SPINES.length];
return { spineColor, textColor };
}
export function coverExists(coverImage?: string) {
return (
!!coverImage &&
fs.existsSync(path.join(process.cwd(), "public", coverImage))
);
}
+43
View File
@@ -0,0 +1,43 @@
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import { getWritings } from "@/lib/content";
import { abs } from "@/lib/site";
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeStringify);
/** Post bodies as HTML with root-relative links made absolute, for feed readers. */
export async function getFeedItems() {
return Promise.all(
getWritings().map(async (p) => {
const html = String(await processor.process(p.content)).replace(
/(src|href)="\/(?!\/)/g,
`$1="${abs("/")}`,
);
return {
slug: p.slug,
url: abs(`/writing/${p.slug}`),
title: p.data.title,
description: p.data.description ?? "",
date: new Date(String(p.data.date)),
tags: p.data.tags ?? [],
html,
};
}),
);
}
export const xml = (s: string) =>
s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
+56
View File
@@ -0,0 +1,56 @@
import { ImageResponse } from "next/og";
import { site } from "@/lib/site";
export const ogSize = { width: 1200, height: 630 };
export const ogContentType = "image/png";
/** 1200x630 social card: title on white, name and domain along the bottom. */
export function ogImage(title: string, kicker?: string) {
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
background: "#ffffff",
color: "#1a202c",
padding: "72px 80px",
borderLeft: "16px solid #2b6cb0",
}}
>
<div
style={{
fontSize: 30,
color: "#718096",
textTransform: "uppercase",
letterSpacing: 3,
}}
>
{kicker ?? ""}
</div>
<div
style={{
fontSize: title.length > 60 ? 56 : 72,
fontWeight: 700,
lineHeight: 1.15,
display: "flex",
}}
>
{title}
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 30,
}}
>
<span style={{ color: "#1a202c" }}>{site.name}</span>
<span style={{ color: "#718096" }}>andwati.com</span>
</div>
</div>,
ogSize,
);
}
+45
View File
@@ -0,0 +1,45 @@
export const site = {
name: "Ian Andwati",
alias: "mockingspectre",
title: "Ian Andwati (mockingspectre) | Software Engineer & CTF Player",
tagline: "Thoughts and musings",
description:
"Software engineer and captain of the binarysith CTF team. Essays on low-level programming, distributed systems, compilers, database internals and security.",
baseUrl: "https://andwati.com",
locale: "en_US",
twitter: "@andwati_",
keywords: [
"Ian Andwati",
"mockingspectre",
"binarysith",
"software engineering",
"cyber security",
"CTF writeups",
"binary exploitation",
"reverse engineering",
"low-level programming",
"distributed systems",
"compiler design",
"database internals",
],
nav: [
{ name: "Home", href: "/" },
{ name: "Reading", href: "/reading" },
{ name: "Writing", href: "/writing" },
{ name: "Portfolio", href: "/portfolio" },
],
social: [
{ name: "GitHub", href: "https://github.com/andwati" },
{ name: "Twitter", href: "https://twitter.com/andwati_" },
{ name: "YouTube", href: "https://www.youtube.com/@pwnforfunandprofit" },
{ name: "Email", href: "mailto:[email protected]" },
],
sameAs: [
"https://github.com/andwati",
"https://twitter.com/andwati_",
"https://www.youtube.com/@pwnforfunandprofit",
"https://ctftime.org/team/379200",
],
};
export const abs = (path: string) => new URL(path, site.baseUrl).toString();