diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..3efb645 --- /dev/null +++ b/app/blog/[slug]/page.tsx @@ -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 }, + parent: ResolvingMetadata, +): Promise { + const { slug } = await params; + const post = getPost(slug); + 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; +}) { + 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..805c118 --- /dev/null +++ b/app/blog/page.tsx @@ -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 { + 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 ( +
+ +
+ field notes +

+ field notes. +

+

+ long-form posts on the deCDN protocol — what it is, why now, and how + the pieces fit together. published when something's worth + saying. +

+
+ + + + {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..ddfda28 100644 --- a/app/globals.css +++ b/app/globals.css @@ -818,3 +818,152 @@ 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. + 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 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; +} diff --git a/app/sitemap-pages.xml/route.ts b/app/sitemap-pages.xml/route.ts index cefc622..0fe0b43 100644 --- a/app/sitemap-pages.xml/route.ts +++ b/app/sitemap-pages.xml/route.ts @@ -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) => ` ${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..194b040 100644 --- a/components/site/Chrome.tsx +++ b/components/site/Chrome.tsx @@ -1,9 +1,15 @@ "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; +// Hash anchors below are written `/#section`, not `#section`. The +// leading slash is load-bearing: this nav also renders on `/blog/*`, +// where a bare `#section` would resolve relative to the current path +// and land on `/blog/#section` (which doesn't exist) instead of +// `/#section` on the home page. const NAV = [ { id: "compare", label: "compare" }, { id: "method", label: "method" }, @@ -122,8 +128,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 +152,21 @@ export function Chrome() { className={onDark ? "block" : "hidden"} /> labs - +