|
|
|
|
@@ -1,38 +1,186 @@
|
|
|
|
|
import { getPostBySlug, getPostSlugs } from '@/lib/mdx';
|
|
|
|
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
import NextImage, { type ImageProps } from 'next/image';
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
import { notFound } from 'next/navigation';
|
|
|
|
|
import { format } from 'date-fns';
|
|
|
|
|
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
|
|
|
|
import { isValidElement } from 'react';
|
|
|
|
|
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
|
|
|
import { TableOfContents } from '@/components/mdx/TableOfContents';
|
|
|
|
|
import { SideNote } from '@/components/mdx/SideNote';
|
|
|
|
|
import { Citation, Bibliography } from '@/components/mdx/Citation';
|
|
|
|
|
import { MobileTableOfContents } from '@/components/mdx/MobileTableOfContents';
|
|
|
|
|
import { formatPostDate } from '@/lib/format';
|
|
|
|
|
|
|
|
|
|
const slugify = (text: React.ReactNode): string => {
|
|
|
|
|
if (!text) return '';
|
|
|
|
|
const str = typeof text === 'string' ? text : String(text);
|
|
|
|
|
return str
|
|
|
|
|
const slugify = (text: string): string => {
|
|
|
|
|
return text
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
|
|
|
.replace(/^-+|-+$/g, '');
|
|
|
|
|
.replace(/^-+|-+$/g, '') || 'section';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const components = {
|
|
|
|
|
h1: (props: React.ComponentPropsWithoutRef<'h1'>) => <h1 {...props} id={slugify(props.children)} className="mt-12 scroll-mt-24 font-sans text-[2rem] font-medium leading-tight tracking-[-0.05em] text-ink sm:text-[2.5rem]" />,
|
|
|
|
|
h2: (props: React.ComponentPropsWithoutRef<'h2'>) => <h2 {...props} id={slugify(props.children)} className="mt-14 scroll-mt-24 font-sans text-[1.65rem] font-medium leading-tight tracking-[-0.05em] text-ink sm:text-[2rem]" />,
|
|
|
|
|
h3: (props: React.ComponentPropsWithoutRef<'h3'>) => <h3 {...props} id={slugify(props.children)} className="mt-10 scroll-mt-24 font-sans text-[1.22rem] font-medium tracking-[-0.03em] text-ink sm:text-[1.42rem]" />,
|
|
|
|
|
p: (props: React.ComponentPropsWithoutRef<'p'>) => <p {...props} className="mb-6 text-[1.02rem] leading-[1.85] text-ink-soft" />,
|
|
|
|
|
ul: (props: React.ComponentPropsWithoutRef<'ul'>) => <ul {...props} className="mb-6 list-disc space-y-2 pl-5 text-[0.98rem] leading-8 text-ink-soft marker:text-accent" />,
|
|
|
|
|
ol: (props: React.ComponentPropsWithoutRef<'ol'>) => <ol {...props} className="mb-6 list-decimal space-y-2 pl-5 text-[0.98rem] leading-8 text-ink-soft marker:text-accent" />,
|
|
|
|
|
blockquote: (props: React.ComponentPropsWithoutRef<'blockquote'>) => <blockquote {...props} className="my-10 border-l border-line-strong pl-5 text-[1.05rem] italic leading-8 text-ink-soft" />,
|
|
|
|
|
code: (props: React.ComponentPropsWithoutRef<'code'>) => <code {...props} className="rounded bg-paper-strong px-1.5 py-0.5 font-mono text-[0.9em] text-ink" />,
|
|
|
|
|
pre: (props: React.ComponentPropsWithoutRef<'pre'>) => <pre {...props} className="my-8 overflow-x-auto rounded-[1rem] border border-line bg-[#161412] p-5 text-sm text-paper shadow-[0_16px_40px_rgba(17,16,15,0.12)]" />,
|
|
|
|
|
a: (props: React.ComponentPropsWithoutRef<'a'>) => <a {...props} className="font-medium text-ink underline decoration-line-strong underline-offset-4 transition-colors hover:text-accent hover:decoration-accent" />,
|
|
|
|
|
SideNote,
|
|
|
|
|
Citation,
|
|
|
|
|
Bibliography,
|
|
|
|
|
};
|
|
|
|
|
const publicDirectory = path.join(process.cwd(), 'public');
|
|
|
|
|
const imageDimensionCache = new Map<string, { width: number; height: number } | null>();
|
|
|
|
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
|
const JPEG_START_OF_FRAME_MARKERS = new Set([
|
|
|
|
|
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
function normalizeHeadingText(text: string) {
|
|
|
|
|
return text
|
|
|
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
|
|
|
.replace(/[`*_~]/g, '')
|
|
|
|
|
.replace(/<[^>]+>/g, '')
|
|
|
|
|
.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createSlugger() {
|
|
|
|
|
const counts = new Map<string, number>();
|
|
|
|
|
|
|
|
|
|
return (text: string) => {
|
|
|
|
|
const base = slugify(text);
|
|
|
|
|
const count = counts.get(base) || 0;
|
|
|
|
|
counts.set(base, count + 1);
|
|
|
|
|
return count === 0 ? base : `${base}-${count}`;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toPlainText(node: ReactNode): string {
|
|
|
|
|
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
|
|
|
if (Array.isArray(node)) return node.map(toPlainText).join('');
|
|
|
|
|
if (isValidElement<{ children?: ReactNode }>(node)) return toPlainText(node.props.children);
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractHeadings(content: string) {
|
|
|
|
|
const slugger = createSlugger();
|
|
|
|
|
const headingLines = content.match(/^#{1,3}\s+(.+)$/gm) || [];
|
|
|
|
|
|
|
|
|
|
return headingLines.flatMap((line) => {
|
|
|
|
|
const level = line.match(/^#+/)?.[0].length || 2;
|
|
|
|
|
const text = normalizeHeadingText(line.replace(/^#+\s+/, ''));
|
|
|
|
|
const id = slugger(text);
|
|
|
|
|
return level > 1 ? [{ id, text, level }] : [];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPngDimensions(buffer: Buffer) {
|
|
|
|
|
if (buffer.length < 24 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
|
|
|
|
|
return {
|
|
|
|
|
width: buffer.readUInt32BE(16),
|
|
|
|
|
height: buffer.readUInt32BE(20),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getJpegDimensions(buffer: Buffer) {
|
|
|
|
|
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
|
|
|
|
|
|
|
|
|
|
let offset = 2;
|
|
|
|
|
while (offset < buffer.length - 9) {
|
|
|
|
|
if (buffer[offset] !== 0xff) {
|
|
|
|
|
offset += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const marker = buffer[offset + 1];
|
|
|
|
|
offset += 2;
|
|
|
|
|
|
|
|
|
|
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const length = buffer.readUInt16BE(offset);
|
|
|
|
|
if (length < 2 || offset + length > buffer.length) break;
|
|
|
|
|
|
|
|
|
|
if (JPEG_START_OF_FRAME_MARKERS.has(marker)) {
|
|
|
|
|
return {
|
|
|
|
|
height: buffer.readUInt16BE(offset + 3),
|
|
|
|
|
width: buffer.readUInt16BE(offset + 5),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
offset += length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getLocalImageDimensions(src: string) {
|
|
|
|
|
const pathname = src.split(/[?#]/, 1)[0];
|
|
|
|
|
if (!pathname.startsWith('/') || pathname.includes('\0') || pathname.includes('..')) return null;
|
|
|
|
|
|
|
|
|
|
const imagePath = path.join(publicDirectory, pathname.replace(/^\/+/, ''));
|
|
|
|
|
if (!imagePath.startsWith(`${publicDirectory}${path.sep}`)) return null;
|
|
|
|
|
|
|
|
|
|
if (imageDimensionCache.has(imagePath)) {
|
|
|
|
|
return imageDimensionCache.get(imagePath) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const buffer = fs.readFileSync(imagePath);
|
|
|
|
|
const dimensions = getPngDimensions(buffer) ?? getJpegDimensions(buffer);
|
|
|
|
|
imageDimensionCache.set(imagePath, dimensions);
|
|
|
|
|
return dimensions;
|
|
|
|
|
} catch {
|
|
|
|
|
imageDimensionCache.set(imagePath, null);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function numericDimension(value: ImageProps['width'] | ImageProps['height']) {
|
|
|
|
|
if (typeof value === 'number') return value;
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MdxImage({ src, alt, width, height, sizes, ...props }: ImageProps) {
|
|
|
|
|
if (typeof src !== 'string') {
|
|
|
|
|
return <NextImage {...props} src={src} alt={alt} width={width} height={height} sizes={sizes} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const localDimensions = getLocalImageDimensions(src);
|
|
|
|
|
const resolvedWidth = numericDimension(width) ?? localDimensions?.width;
|
|
|
|
|
const resolvedHeight = numericDimension(height) ?? localDimensions?.height;
|
|
|
|
|
const displayWidth = Math.min(resolvedWidth ?? 704, 704);
|
|
|
|
|
const responsiveSizes = sizes ?? `(max-width: 768px) 100vw, ${displayWidth}px`;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<NextImage
|
|
|
|
|
{...props}
|
|
|
|
|
src={src}
|
|
|
|
|
alt={alt}
|
|
|
|
|
width={resolvedWidth ?? 1200}
|
|
|
|
|
height={resolvedHeight ?? 675}
|
|
|
|
|
sizes={responsiveSizes}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createMdxComponents() {
|
|
|
|
|
const slugger = createSlugger();
|
|
|
|
|
const getHeadingId = (children: ReactNode) => slugger(toPlainText(children));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
h1: ({ children, ...props }: ComponentPropsWithoutRef<'h1'>) => <h1 {...props} id={getHeadingId(children)} className="mt-12 scroll-mt-24 font-sans text-[2rem] font-medium leading-tight text-ink sm:text-[2.5rem]">{children}</h1>,
|
|
|
|
|
h2: ({ children, ...props }: ComponentPropsWithoutRef<'h2'>) => <h2 {...props} id={getHeadingId(children)} className="mt-14 scroll-mt-24 font-sans text-[1.65rem] font-medium leading-tight text-ink sm:text-[2rem]">{children}</h2>,
|
|
|
|
|
h3: ({ children, ...props }: ComponentPropsWithoutRef<'h3'>) => <h3 {...props} id={getHeadingId(children)} className="mt-10 scroll-mt-24 font-sans text-[1.22rem] font-medium text-ink sm:text-[1.42rem]">{children}</h3>,
|
|
|
|
|
p: (props: ComponentPropsWithoutRef<'p'>) => <p {...props} className="mb-6 text-[1.02rem] leading-[1.85] text-ink-soft" />,
|
|
|
|
|
ul: (props: ComponentPropsWithoutRef<'ul'>) => <ul {...props} className="mb-6 list-disc space-y-2 pl-5 text-[0.98rem] leading-8 text-ink-soft marker:text-accent" />,
|
|
|
|
|
ol: (props: ComponentPropsWithoutRef<'ol'>) => <ol {...props} className="mb-6 list-decimal space-y-2 pl-5 text-[0.98rem] leading-8 text-ink-soft marker:text-accent" />,
|
|
|
|
|
blockquote: (props: ComponentPropsWithoutRef<'blockquote'>) => <blockquote {...props} className="my-10 border-l border-line-strong pl-5 text-[1.05rem] italic leading-8 text-ink-soft" />,
|
|
|
|
|
code: (props: ComponentPropsWithoutRef<'code'>) => <code {...props} className="rounded bg-paper-strong px-1.5 py-0.5 font-mono text-[0.9em] text-ink" />,
|
|
|
|
|
pre: (props: ComponentPropsWithoutRef<'pre'>) => <pre {...props} className="my-8 overflow-x-auto rounded-[1rem] border border-line bg-[#161412] p-5 text-sm text-paper shadow-[0_16px_40px_rgba(17,16,15,0.12)]" />,
|
|
|
|
|
a: (props: ComponentPropsWithoutRef<'a'>) => <a {...props} className="font-medium text-ink underline decoration-line-strong underline-offset-4 transition-colors hover:text-accent hover:decoration-accent" />,
|
|
|
|
|
Image: MdxImage,
|
|
|
|
|
SideNote,
|
|
|
|
|
Citation,
|
|
|
|
|
Bibliography,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
params: Promise<{ slug: string }>;
|
|
|
|
|
@@ -80,13 +228,8 @@ export default async function BlogPost({ params }: Props) {
|
|
|
|
|
notFound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const headingLines = post.content.match(/^#{2,3}\s+(.+)$/gm) || [];
|
|
|
|
|
const headings = headingLines.map((line) => {
|
|
|
|
|
const level = line.match(/^#+/)?.[0].length || 2;
|
|
|
|
|
const text = line.replace(/^#+\s+/, '').trim();
|
|
|
|
|
const id = slugify(text);
|
|
|
|
|
return { id, text, level };
|
|
|
|
|
});
|
|
|
|
|
const headings = extractHeadings(post.content);
|
|
|
|
|
const mdxComponents = createMdxComponents();
|
|
|
|
|
const tags = Array.isArray(post.metadata.tags) ? post.metadata.tags : [];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
@@ -100,16 +243,16 @@ export default async function BlogPost({ params }: Props) {
|
|
|
|
|
|
|
|
|
|
<article className="w-full">
|
|
|
|
|
<header className="mb-8 space-y-4 border-b border-line pb-8">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.72rem] font-mono uppercase tracking-[0.14em] text-muted-strong">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.72rem] font-mono uppercase text-muted-strong">
|
|
|
|
|
<time dateTime={post.metadata.date}>
|
|
|
|
|
{format(new Date(post.metadata.date), 'MMMM d, yyyy')}
|
|
|
|
|
{formatPostDate(post.metadata.date)}
|
|
|
|
|
</time>
|
|
|
|
|
{tags.map((tag: string) => (
|
|
|
|
|
<span key={tag}>{tag}</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<h1 className="max-w-[38rem] text-balance font-sans text-[clamp(2.7rem,5.8vw,4.4rem)] font-medium leading-[0.94] tracking-[-0.08em] text-ink">
|
|
|
|
|
<h1 className="max-w-[38rem] text-balance font-sans text-4xl font-medium leading-[0.98] text-ink sm:text-5xl lg:text-6xl">
|
|
|
|
|
{post.metadata.title}
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
@@ -123,14 +266,7 @@ export default async function BlogPost({ params }: Props) {
|
|
|
|
|
<div className="essay-prose relative mt-8 max-w-none xl:max-w-[44rem]">
|
|
|
|
|
<MDXRemote
|
|
|
|
|
source={post.content}
|
|
|
|
|
components={components}
|
|
|
|
|
options={{
|
|
|
|
|
mdxOptions: {
|
|
|
|
|
rehypePlugins: [
|
|
|
|
|
[rehypeAutolinkHeadings, { behavior: 'wrap' }]
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
components={mdxComponents}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@@ -138,7 +274,7 @@ export default async function BlogPost({ params }: Props) {
|
|
|
|
|
<Link href="/blog" className="transition-colors hover:text-ink">
|
|
|
|
|
← Back to all posts
|
|
|
|
|
</Link>
|
|
|
|
|
<a href="#" className="transition-colors hover:text-ink">
|
|
|
|
|
<a href="#main-content" className="transition-colors hover:text-ink">
|
|
|
|
|
Scroll to top ↑
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
|