mirror of
https://github.com/andwati/andwati.com.git
synced 2026-11-12 03:45:12 +00:00
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:
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function NotFound() {
|
||||
redirect("/");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(" - ");
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user