Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import { Frame } from "@/components/ui/Frame";
import { Prose } from "@/components/ui/Prose";
import { getPost, listPosts } from "@/lib/blog";

// Static export: enumerate every slug at build time and refuse anything
// outside that set. `dynamicParams = false` mirrors the closed-world
// nature of `output: "export"` — there is no runtime to honour an
// unknown slug.
export const dynamicParams = false;

export function generateStaticParams() {
return listPosts().map((p) => ({ slug: p.slug }));
}

type Params = { slug: string };

export async function generateMetadata(
{ params }: { params: Promise<Params> },
parent: ResolvingMetadata,
): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
Comment thread
alpergundogdu marked this conversation as resolved.
if (!post) notFound();
// Inherit the root file-convention og image so post shares get the
// site card instead of a blank one; see app/blog/page.tsx for the
// pattern and the reason we reuse ogImages for twitter.
const ogImages = (await parent).openGraph?.images ?? [];
return {
title: post.title,
description: post.summary,
alternates: { canonical: `/blog/${post.slug}/` },
openGraph: {
title: post.title,
description: post.summary,
url: `/blog/${post.slug}/`,
type: "article",
publishedTime: post.date,
images: ogImages,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.summary,
images: ogImages,
},
};
}

export default async function BlogPost({
params,
}: {
params: Promise<Params>;
}) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();

return (
<main>
<Frame id="post" tone="paper">
<article className="flex flex-col gap-10">
<header className="flex flex-col gap-6">
<time className="meta opacity-60" dateTime={post.date}>
{post.date}
</time>
<h1
className="hug font-semibold leading-[0.95] tracking-[-0.035em]"
style={{ fontSize: "var(--fs-h2)" }}
>
{post.title}
</h1>
</header>

<span aria-hidden className="rule opacity-20" />

<Prose>
<MDXRemote
source={post.body}
options={{ mdxOptions: { remarkPlugins: [remarkGfm] } }}
/>
</Prose>
</article>
</Frame>
</main>
);
}
114 changes: 114 additions & 0 deletions app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { Metadata, ResolvingMetadata } from "next";
import Link from "next/link";
import { Frame } from "@/components/ui/Frame";
import { listPosts } from "@/lib/blog";

const TITLE = "field notes";
const DESCRIPTION = "long-form posts on the deCDN protocol.";

// Inherit the root file-convention og image (app/opengraph-image.png).
// Next merges flat fields but shallow-replaces nested objects like
// `openGraph`, so declaring an `openGraph` here without re-including
// `images` strips the root image — which previously left every blog
// route sharing a blank card. We reuse `ogImages` for twitter too:
// the parent `twitter.images` field is empty here (no app/twitter-image
// convention exists) and Next's "fall back to og for twitter" only
// fires at the final resolution step, not via `parent`.
export async function generateMetadata(
_: unknown,
parent: ResolvingMetadata,
): Promise<Metadata> {
const ogImages = (await parent).openGraph?.images ?? [];
return {
title: TITLE,
description: DESCRIPTION,
alternates: { canonical: "/blog/" },
openGraph: {
title: TITLE,
description: DESCRIPTION,
url: "/blog/",
type: "website",
images: ogImages,
},
twitter: {
card: "summary_large_image",
title: TITLE,
description: DESCRIPTION,
images: ogImages,
},
};
}

export default function BlogIndex() {
const posts = listPosts();

return (
<main>
<Frame id="blog" tone="paper">
<header className="flex flex-col gap-6">
<span className="meta opacity-60">field notes</span>
<h1
className="hug font-semibold leading-[0.92] tracking-[-0.04em]"
style={{ fontSize: "var(--fs-h2)" }}
>
field notes.
</h1>
<p
className="max-w-[60ch] leading-[1.7] opacity-75"
style={{ fontSize: "var(--fs-lead)" }}
>
long-form posts on the deCDN protocol — what it is, why now, and how
the pieces fit together. published when something&apos;s worth
saying.
</p>
</header>

<span aria-hidden className="rule mt-16 opacity-20" />

{posts.length === 0 ? (
<p
className="mt-16 opacity-60"
style={{ fontSize: "var(--fs-body)" }}
>
nothing yet.
</p>
) : (
<ul className="mt-16 flex flex-col divide-y divide-current/15">
{posts.map((post) => (
<li key={post.slug}>
<Link
href={`/blog/${post.slug}/`}
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
className="group grid gap-2 py-8 no-underline @xl:grid-cols-[10rem_1fr] @xl:gap-10"
>
<time
className="meta opacity-60"
dateTime={post.date}
style={{ alignSelf: "start" }}
>
{post.date}
</time>
<div className="flex flex-col gap-3">
<h2
className="hug font-semibold leading-[1.05] tracking-[-0.025em] group-hover:opacity-80"
style={{ fontSize: "var(--fs-h3)" }}
>
{post.title}
</h2>
{post.summary ? (
<p
className="max-w-[60ch] leading-[1.6] opacity-75"
style={{ fontSize: "var(--fs-body)" }}
>
{post.summary}
</p>
) : null}
</div>
</Link>
</li>
))}
</ul>
)}
</Frame>
</main>
);
}
149 changes: 149 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -818,3 +818,152 @@ a.underline-brutal:hover .arrow {
transition: none;
}
}

/* --- prose: MDX body styling ------------------------------------------ */
/* Long-form post body. Wrapped in <Prose>. Tokens drawn from the shared
scale (--fs-body, --fs-lead, --fs-h3) so headings track the same
responsive curve as everywhere else on the site.
We deliberately do not pull in @tailwindcss/typography: Tailwind v4
with @theme inline in globals.css makes the plugin awkward to wire,
and the plugin's defaults override our shared --fs-* scale, which is
what we wanted to keep. The block is paper-tone only by current
usage (every <Frame> wrapping it is tone="paper"); if it's ever
reused on a dark surface, swap var(--ink) → currentColor here and
in the color-mix() calls below. */

.prose {
max-width: 68ch;
color: var(--ink);
}

.prose > * + * {
margin-block-start: 1.4em;
}

.prose h2 {
font-feature-settings:
"ss01" on,
"zero" on;
font-variant-numeric: tabular-nums;
font-size: var(--fs-h3);
font-weight: 600;
letter-spacing: -0.03em;
line-height: 1.05;
margin-block-start: 2.4em;
}

.prose h3 {
font-size: var(--fs-lead);
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
margin-block-start: 2em;
}

.prose p,
.prose li {
font-size: var(--fs-body);
line-height: 1.7;
}

.prose ul,
.prose ol {
padding-inline-start: 1.4em;
}

.prose ul > li {
list-style: disc;
}

.prose ol > li {
list-style: decimal;
}

.prose li + li {
margin-block-start: 0.4em;
}

.prose strong {
font-weight: 600;
}

.prose em {
font-style: italic;
}

.prose a {
color: inherit;
text-decoration: none;
border-bottom: 1px solid currentColor;
transition: border-bottom-color 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.prose a:hover {
border-bottom-color: color-mix(in srgb, currentColor 40%, transparent);
}

.prose a:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
border-radius: 2px;
}

.prose code {
font-family:
ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Monaco, "Cascadia Mono",
"Courier New", monospace;
font-size: 0.92em;
padding: 0.05em 0.3em;
background: color-mix(in srgb, var(--ink) 6%, transparent);
border-radius: 3px;
}

.prose pre {
font-family:
ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Monaco, "Cascadia Mono",
"Courier New", monospace;
font-size: 0.875rem;
line-height: 1.65;
padding: 1em 1.2em;
background: color-mix(in srgb, var(--ink) 5%, transparent);
border: 1px dashed color-mix(in srgb, var(--ink) 25%, transparent);
border-radius: 4px;
overflow-x: auto;
}

.prose pre code {
background: none;
padding: 0;
font-size: inherit;
}

.prose table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-body);
}

.prose th,
.prose td {
padding: 0.55em 0.8em;
border-bottom: 1px solid color-mix(in srgb, var(--ink) 14%, transparent);
text-align: left;
vertical-align: top;
}

.prose th {
font-weight: 600;
letter-spacing: -0.01em;
}

.prose blockquote {
padding-inline-start: 1em;
border-inline-start: 2px solid color-mix(in srgb, var(--ink) 30%, transparent);
color: color-mix(in srgb, var(--ink) 75%, transparent);
}

.prose hr {
border: 0;
border-top: 1px solid color-mix(in srgb, var(--ink) 15%, transparent);
margin-block: 2.4em;
}
13 changes: 13 additions & 0 deletions app/sitemap-pages.xml/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { SITE_URL } from "@/lib/links";
import { listPosts } from "@/lib/blog";

// Required by Next 16 under `output: "export"` for route handlers that emit
// static files at build time. GET-only is the only verb supported in export.
export const dynamic = "force-static";

// Same source as `generateStaticParams` in app/blog/[slug]/page.tsx, so the
// sitemap can never list a URL the build didn't actually emit.
// Raw-interpolating slug into XML is safe only because `SLUG_RE` in
// lib/blog.ts forbids `&`, `<`, `>`; if that regex ever loosens this
// must move to an XML-encoder, or search engines silently reject the
// sitemap.
const postUrls = listPosts()
.map((p) => ` <url><loc>${SITE_URL}blog/${p.slug}/</loc></url>`)
.join("\n");

const BODY = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>${SITE_URL}</loc></url>
<url><loc>${SITE_URL}blog/</loc></url>
${postUrls}
</urlset>
`;

Expand Down
Loading
Loading