From 9ded6d06a7abd2fcfd7ea2ecf2605f249164d957 Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Mon, 11 May 2026 12:50:13 +0100 Subject: [PATCH 1/8] feat(blog): add /blog with markdown-sourced static post pages Closes #60. - content/blog/*.mdx is the source; gray-matter parses frontmatter, next-mdx-remote/rsc compiles bodies at build via remark-gfm. - /blog index lists posts (date, title, summary), newest first. - /blog/[slug] uses generateStaticParams + dynamicParams=false so the closed-world set lines up with the static export. - Sitemap pulls slugs from the same listPosts() source so the URL set can't drift from generateStaticParams. - New .prose rule in globals.css styles MDX body output using existing --fs-* tokens (no Tailwind typography plugin under v4). - Chrome.tsx gains a Blog link and switches hash anchors to absolute (/#section) so global nav still works from /blog. Seeds the first post from internal/Blog/00-what-were-building.md. --- app/blog/[slug]/page.tsx | 85 +++ app/blog/page.tsx | 89 +++ app/globals.css | 133 ++++ app/sitemap-pages.xml/route.ts | 9 + components/site/Chrome.tsx | 21 +- components/ui/Prose.tsx | 5 + content/blog/00-what-were-building.mdx | 65 ++ lib/blog.ts | 71 ++ lib/links.ts | 1 + package.json | 5 +- pnpm-lock.yaml | 988 +++++++++++++++++++++++++ 11 files changed, 1465 insertions(+), 7 deletions(-) create mode 100644 app/blog/[slug]/page.tsx create mode 100644 app/blog/page.tsx create mode 100644 components/ui/Prose.tsx create mode 100644 content/blog/00-what-were-building.mdx create mode 100644 lib/blog.ts diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..df70417 --- /dev/null +++ b/app/blog/[slug]/page.tsx @@ -0,0 +1,85 @@ +import type { Metadata } 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; +}): Promise { + const { slug } = await params; + const post = getPost(slug); + if (!post) return {}; + 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, + }, + twitter: { + card: "summary_large_image", + title: post.title, + description: post.summary, + }, + }; +} + +export default async function BlogPost({ + params, +}: { + params: Promise; +}) { + const { slug } = await params; + const post = getPost(slug); + if (!post) notFound(); + + return ( +
+ +
+
+ +

+ {post.title} +

+
+ + + + + + +
+ +
+ ); +} diff --git a/app/blog/page.tsx b/app/blog/page.tsx new file mode 100644 index 0000000..aaeb2ab --- /dev/null +++ b/app/blog/page.tsx @@ -0,0 +1,89 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Frame } from "@/components/ui/Frame"; +import { listPosts } from "@/lib/blog"; + +export const metadata: Metadata = { + title: "field notes", + description: "long-form posts on the deCDN protocol.", + alternates: { canonical: "/blog/" }, + openGraph: { + title: "field notes", + description: "long-form posts on the deCDN protocol.", + url: "/blog/", + type: "website", + }, +}; + +export default function BlogIndex() { + const posts = listPosts(); + + return ( +
+ +
+ field notes +

+ field notes. +

+

+ long-form posts on the deCDN protocol — what it is, why now, and how + the pieces fit together. published every two weeks. +

+
+ + + + {posts.length === 0 ? ( +

+ nothing yet. +

+ ) : ( +
    + {posts.map((post) => ( +
  • + + +
    +

    + {post.title} +

    + {post.summary ? ( +

    + {post.summary} +

    + ) : null} +
    + +
  • + ))} +
+ )} + +
+ ); +} diff --git a/app/globals.css b/app/globals.css index 195a766..1f7ebd1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -818,3 +818,136 @@ a.underline-brutal:hover .arrow { transition: none; } } + +/* --- prose: MDX body styling ------------------------------------------ */ +/* Long-form post body. Wrapped in . 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. Subsurfaces (code, + table borders, blockquote rules) are color-mixed against --ink so the + block stays tone-agnostic if we ever drop it onto a dark backdrop. */ + +.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; +} + +.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; +} diff --git a/app/sitemap-pages.xml/route.ts b/app/sitemap-pages.xml/route.ts index cefc622..9624d85 100644 --- a/app/sitemap-pages.xml/route.ts +++ b/app/sitemap-pages.xml/route.ts @@ -1,12 +1,21 @@ 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. +const postUrls = listPosts() + .map((p) => ` ${SITE_URL}blog/${p.slug}/`) + .join("\n"); + const BODY = ` ${SITE_URL} + ${SITE_URL}blog/ +${postUrls} `; diff --git a/components/site/Chrome.tsx b/components/site/Chrome.tsx index 528eac3..134512c 100644 --- a/components/site/Chrome.tsx +++ b/components/site/Chrome.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; import { links } from "@/lib/links"; const SECTION_IDS = ["intro", "compare", "method", "faq", "contact"] as const; @@ -122,8 +123,8 @@ export function Chrome() { className="mx-auto grid w-full grid-cols-[1fr_auto_1fr] items-center gap-4" style={{ maxWidth: "var(--frame-max)" }} > - {/* Both variants stay mounted and toggled via `display` so the @@ -146,21 +147,21 @@ export function Chrome() { className={onDark ? "block" : "hidden"} /> labs - +