Commit 8513a371 by PLN (Algolia)

feat(blog): blog core — unified remark-rehype pipeline, App Router pages, post scripts

The one render chain: remark-gfm, remark-directive (mapped-tag escape
hatch for future embed islands), rehype-figure, rehype-pretty-code with
vesper highlighting at build time. Shipped pages are flat HTML with
inline token colors and zero client JS.

- lib/markdown.ts mints all /blog HTML; lib/blog.js binds the section
- app/blog + app/blog/[slug] on the App Router (SSG, generateMetadata);
  app/post/[id] and lib/posts.js removed with the route
- content/posts moved to content/blog; the 2021 entries are draft:true,
  hello-world-again is the one published post (engine fixture: figure
  captioning, shiki, GFM table) with its edge.png image
- yarn post / yarn post:image scaffold posts and stage images
- e2e smoke routes follow the rename
parent 7b5fdf9a
import Layout from "@/components/layout";
import Date from "@/components/date";
import utilStyles from "@/styles/utils.module.css";
import styles from "../blog.module.css";
import { getAllBlogSlugs, getBlogPost } from "@/lib/blog";
export function generateStaticParams() {
return getAllBlogSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const post = await getBlogPost(slug);
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
url: `/blog/${post.slug}`,
publishedTime: post.date,
images: ['/images/profile.png'],
},
};
}
export default async function BlogPost({ params }) {
const { slug } = await params;
const post = await getBlogPost(slug);
return (
<Layout>
<article className={styles.article}>
<h1 className={utilStyles.headingXl}>{post.title}</h1>
<div className={`${styles.meta} ${utilStyles.lightText}`}>
<Date dateString={post.date} />
{post.tags && post.tags.length > 0 && (
<ul className={styles.tags}>
{post.tags.map((tag) => (
<li key={tag}>#{tag}</li>
))}
</ul>
)}
</div>
{post.description && <p className={styles.description}>{post.description}</p>}
<div className={styles.prose} dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
</Layout>
);
}
/* /blog styling. Two layers:
* - hashed classes (.article, .prose, …) for structure the pages control;
* - :global(.prose-*) / :global(.directive-note) for classes the markdown
* pipeline emits (lib/markdown.ts) — plain, stable names so the lib stays
* decoupled from any CSS module. Element selectors inside .prose style the
* ordinary markdown output (tables, lists, code) the same way.
*/
.article {
max-width: 42rem;
margin: 0 auto;
padding-bottom: 4rem;
word-wrap: break-word;
}
.meta {
margin: 0.25rem 0 0;
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.tags {
list-style: none;
display: flex;
gap: 0.5rem;
margin: 0;
padding: 0;
font-size: 0.85rem;
}
.tags li {
color: var(--text-muted);
}
.description {
margin: 1rem 0 0;
font-size: 1.1rem;
color: var(--text-muted);
}
/* ------------------------------------------------------------------ *
* Prose body — figures, tables, code, directive notes
* ------------------------------------------------------------------ */
.prose {
margin-top: 2rem;
line-height: 1.7;
font-size: 1.05rem;
}
.prose h2,
.prose h3 {
margin: 2rem 0 0.75rem;
letter-spacing: -0.02rem;
}
.prose a {
color: var(--link);
}
.prose blockquote {
margin: 1.5rem 0;
padding: 0.25rem 1.25rem;
border-left: 3px solid var(--border);
color: var(--text-muted);
}
/* Lone images wrapped by rehype-figure */
.prose :global(.prose-figure) {
margin: 2rem 0;
text-align: center;
}
.prose :global(.prose-figure img) {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
border-radius: 8px;
}
.prose :global(.prose-figcaption) {
margin-top: 0.6rem;
font-size: 0.85rem;
color: var(--text-muted);
}
/* GFM tables */
.prose table {
border-collapse: collapse;
width: 100%;
margin: 1.5rem 0;
font-size: 0.95rem;
}
.prose th,
.prose td {
border: 1px solid var(--border);
padding: 0.5rem 0.75rem;
text-align: left;
}
.prose th {
background: var(--surface-raised);
}
/* Code — shiki tokens are inline-styled by rehype-pretty-code; this supplies
the dark pre. Vesper's own background is #101010. */
.prose pre {
background: #101010;
color: #d5d5d5;
padding: 1rem 1.25rem;
border-radius: 8px;
overflow-x: auto;
line-height: 1.6;
font-size: 0.9rem;
}
.prose pre code {
display: block;
padding: 0;
background: none;
}
.prose :not(pre) > code {
background: var(--surface-raised);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.88em;
}
/* ::note[...] / ::note directives (lib/markdown.ts directiveComponents) */
.prose :global(.directive-note) {
margin: 1.5rem 0;
padding: 0.75rem 1rem;
border-left: 3px solid var(--link);
border-radius: 0 8px 8px 0;
background: var(--surface-raised);
}
.prose :global(.directive-note p:last-child) {
margin-bottom: 0;
}
/* Draft badge (index, dev only) */
.draftBadge {
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
border: 1px dashed #b8860b;
border-radius: 4px;
color: #b8860b;
font-size: 0.7rem;
letter-spacing: 0.08em;
vertical-align: middle;
}
import Link from "next/link";
import Layout from "@/components/layout";
import Date from "@/components/date";
import utilStyles from "@/styles/utils.module.css";
import styles from "./blog.module.css";
import { getBlogPosts } from "@/lib/blog";
export const metadata = {
title: "Blog",
description: "Notes, project archaeology and background noise.",
};
export default function Blog() {
const posts = getBlogPosts();
return (
<Layout>
<section className={`${utilStyles.headingMd} ${utilStyles.padding1px}`}>
<h1 className={utilStyles.headingXl}>Blog</h1>
<ul className={utilStyles.list}>
{posts.map(({ slug, title, date, description, draft }) => (
<li className={utilStyles.listItem} key={slug}>
<Link href={`/blog/${slug}`} className={utilStyles.listItemLink}>
<span>{title}</span>
</Link>
{draft && <small className={styles.draftBadge}>DRAFT</small>}
<br />
<small className={utilStyles.lightText}>
<Date dateString={date} />
{description ? ` — ${description}` : null}
</small>
<br />
</li>
))}
</ul>
</section>
</Layout>
);
}
import Layout from "@/components/layout";
import Date from "@/components/date";
import utilStyles from "@/styles/utils.module.css";
import { getAllPostIds, getPostData } from "@/lib/posts";
export function generateStaticParams() {
return getAllPostIds().map((id) => ({ id }));
}
export async function generateMetadata({ params }) {
const { id } = await params;
const postData = await getPostData(id);
return { title: postData.title };
}
export default async function Post({ params }) {
const { id } = await params;
const postData = await getPostData(id);
return (
<Layout>
<article>
<h1 className={utilStyles.headingXl}>{postData.title}</h1>
<div className={utilStyles.lightText}>
<Date dateString={postData.date} />
</div>
<div dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
</article>
</Layout>
);
}
---
title: Hello World
date: '2026-09-06'
slug: hello-world-again
description: Engine fixture — proves figure centering, build-time highlighting, tables. Prose to be written.
---
Skeleton post. Each element below exercises one rendering path of the blog engine; prose comes later.
1. Centered figure with caption — image below.
2. Build-time code highlighting — Hydra snippet below.
3. GFM table — bottom of this post.
![A Hydra sketch rendering a glowing nebula, shown in the Hydra editor](/images/blog/hello-world-again/edge.png)
```js
osc(20, 0.01, 1.4)
.kaleid(5)
.mult(osc(10).rotate(0.5))
.out()
```
| element | proves |
| ---------- | -------------------------- |
| figure | image path, caption, centering |
| code block | shiki at build time |
| table | GFM support |
--- ---
title: Hello World title: Hello World
date: '2021-04-23' date: '2021-04-23'
draft: true
--- ---
> _C'est un petit pas pour l'homme, mais un grand pas pour le serverless._ > _C'est un petit pas pour l'homme, mais un grand pas pour le serverless._
......
--- ---
title: NextJs Tutorial title: NextJs Tutorial
date: '2021-04-23' date: '2021-04-23'
draft: true
--- ---
Gotta say the [Vercel Next.JS Tutorial](https://nextjs.org/learn/basics/create-nextjs-app) was a delight to follow. Gotta say the [Vercel Next.JS Tutorial](https://nextjs.org/learn/basics/create-nextjs-app) was a delight to follow.
......
import { getAllContentData, getAllContentIds, getContentData } from '@/lib/content';
/** Blog posts, newest-first. Drafts follow the prod/dev rule in lib/content. */
export function getBlogPosts() {
return getAllContentData('blog', true);
}
/** Slug list — App Router callers map it to params. */
export function getAllBlogSlugs() {
return getAllContentIds('blog');
}
/** Single post with rendered HTML body; description stays plain for metadata. */
export async function getBlogPost(slug) {
return getContentData('blog', slug, { plainDescription: true });
}
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { remark } from 'remark'; import { renderMarkdown } from '../markdown';
import remarkHtml from 'remark-html';
/** /**
* Unified content loader. * Unified content loader.
* *
* Two collection shapes live here, on purpose — not one forced shape: * Two collection shapes live here, on purpose — not one forced shape:
* - flat Markdown collections (posts, poems, talks, hydras): one .md per item * - flat Markdown collections (blog, poems, talks, hydras): one .md per item
* under content/<section>/, frontmatter + rendered HTML body; * under content/<section>/, frontmatter + rendered HTML body;
* - ParVagues "lives": a richer, year-foldered gig collection with optional * - ParVagues "lives": a richer, year-foldered gig collection with optional
* tracks.json and gig-photo folders. * tracks.json and gig-photo folders.
* *
* Thin section wrappers (lib/posts.js, lib/hydras.js, …) bind a section name to * Thin section wrappers (lib/blog.js, lib/hydras.js, …) bind a section name to
* the flat API; the ParVagues pages call the lives API directly. * the flat API; the ParVagues pages call the lives API directly.
*/ */
const CONTENT_ROOT = path.join(process.cwd(), 'content'); const CONTENT_ROOT = path.join(process.cwd(), 'content');
/**
* Draft frontmatter (`draft: true`) is dev-only: prod builds filter it out,
* every other context (dev server, jest, `SHOW_DRAFTS=true` for previews)
* keeps it visible.
*/
function draftsVisible() {
return process.env.NODE_ENV !== 'production' || process.env.SHOW_DRAFTS === 'true';
}
/** Frontmatter carried by every flat-collection item (defaults filled in). */
export interface ContentFrontmatter {
slug: string;
draft: boolean;
[key: string]: any;
}
function normalizeEntry(fileName: string, data: any): ContentFrontmatter {
const id = idFromFileName(fileName);
return { ...data, id, slug: data.slug ?? id, draft: data.draft === true };
}
/* ------------------------------------------------------------------ * /* ------------------------------------------------------------------ *
* Flat Markdown collections — content/<section>/<id>.md * Flat Markdown collections — content/<section>/<id>.md
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
...@@ -35,16 +55,11 @@ function idFromFileName(fileName: string) { ...@@ -35,16 +55,11 @@ function idFromFileName(fileName: string) {
return fileName.replace(/\.md$/, ''); return fileName.replace(/\.md$/, '');
} }
async function renderMarkdown(md: string) {
const processed = await remark().use(remarkHtml).process(md);
return processed.toString();
}
export function getContentDirectory(section: string) { export function getContentDirectory(section: string) {
return sectionDir(section); return sectionDir(section);
} }
/** Frontmatter-only listing (used by index/listing pages). */ /** Frontmatter-only listing (used by index/listing pages). Drafts filtered by the prod/dev rule. */
export function getAllContentData(section: string, sorted = false) { export function getAllContentData(section: string, sorted = false) {
const dir = sectionDir(section); const dir = sectionDir(section);
const entries = fs const entries = fs
...@@ -52,19 +67,40 @@ export function getAllContentData(section: string, sorted = false) { ...@@ -52,19 +67,40 @@ export function getAllContentData(section: string, sorted = false) {
.filter(isMarkdownFile) .filter(isMarkdownFile)
.map((fileName) => { .map((fileName) => {
const { data } = matter(fs.readFileSync(path.join(dir, fileName), 'utf8')); const { data } = matter(fs.readFileSync(path.join(dir, fileName), 'utf8'));
return { id: idFromFileName(fileName), ...data }; return normalizeEntry(fileName, data);
}); })
.filter((entry) => draftsVisible() || !entry.draft);
return sorted ? entries.sort((a, b) => (a.date < b.date ? 1 : -1)) : entries; return sorted ? entries.sort((a, b) => (a.date < b.date ? 1 : -1)) : entries;
} }
/** Plain id list. App Router callers map it to params: ids.map((id) => ({ id })). */ /** Plain id list. App Router callers map it to params: ids.map((id) => ({ id })). Drafts filtered by the prod/dev rule. */
export function getAllContentIds(section: string): string[] { export function getAllContentIds(section: string): string[] {
return fs.readdirSync(sectionDir(section)).filter(isMarkdownFile).map(idFromFileName); return fs
.readdirSync(sectionDir(section))
.filter(isMarkdownFile)
.map(idFromFileName)
.filter((id) => draftsVisible() || !isDraft(section, id));
}
function isDraft(section: string, id: string) {
const fileName = path.join(sectionDir(section), `${id}.md`);
if (!fs.existsSync(fileName)) return false;
const { data } = matter(fs.readFileSync(fileName, 'utf8'));
return data.draft === true;
}
export interface GetContentDataOptions {
/** Keep `description` as plain text (metadata) instead of rendering it to HTML. */
plainDescription?: boolean;
} }
/** Single item with rendered HTML body (and HTML-rendered description, if any). */ /** Single item with rendered HTML body (and HTML-rendered description, if any). */
export async function getContentData(section: string, id: string) { export async function getContentData(
section: string,
id: string,
{ plainDescription = false }: GetContentDataOptions = {},
) {
const fileName = id.endsWith('.md') ? id : `${id}.md`; const fileName = id.endsWith('.md') ? id : `${id}.md`;
const fullPath = path.join(sectionDir(section), fileName); const fullPath = path.join(sectionDir(section), fileName);
...@@ -74,12 +110,13 @@ export async function getContentData(section: string, id: string) { ...@@ -74,12 +110,13 @@ export async function getContentData(section: string, id: string) {
const { data, content } = matter(fs.readFileSync(fullPath, 'utf8')); const { data, content } = matter(fs.readFileSync(fullPath, 'utf8'));
const contentHtml = await renderMarkdown(content); const contentHtml = await renderMarkdown(content);
const entry = normalizeEntry(idFromFileName(fileName), data);
if (typeof data.description === 'string') { if (typeof entry.description === 'string' && !plainDescription) {
data.description = await renderMarkdown(data.description); entry.description = await renderMarkdown(entry.description);
} }
return { id: idFromFileName(fileName), contentHtml, ...data }; return { contentHtml, ...entry };
} }
/* ------------------------------------------------------------------ * /* ------------------------------------------------------------------ *
......
/**
* Markdown render chain — the one place /blog HTML is minted.
*
* unified chain: remark-parse → remark-gfm → remark-directive →
* remark-rehype → rehype-figure (custom, below) → rehype-pretty-code →
* rehype-stringify. All highlighting happens here at build time via shiki;
* the shipped page is flat HTML with inline token colors and zero client JS.
*
* Directives are the escape hatch for future rich embeds (Hydra / Strudel
* islands): `directiveComponents` maps a directive name to a tag + class, the
* CSS module styles that class, and a client component can later take over the
* tag without this pipeline caring. Until islands exist, unknown directives
* are silently dropped by remark-rehype (the unified default).
*/
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkDirective from 'remark-directive';
import remarkRehype from 'remark-rehype';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeStringify from 'rehype-stringify';
import { visit } from 'unist-util-visit';
/**
* Directive name → { tag, className }. The component-map stub: add an entry
* to light a directive up (e.g. `::note[Heads up]` renders the note tag below);
* an island swap later means pointing the tag at a component, nothing more.
*/
export const directiveComponents: Record<string, { tag: string; className: string }> = {
note: { tag: 'div', className: 'directive-note' },
};
/** remark plugin: register mapped directives as hast tags via data.hName. */
export function remarkDirectiveTags() {
return (tree: any) => {
visit(tree, (node: any) => {
if (
node.type !== 'containerDirective' &&
node.type !== 'leafDirective' &&
node.type !== 'textDirective'
) {
return;
}
const mapping = directiveComponents[node.name];
if (!mapping) return;
node.data = node.data || {};
node.data.hName = mapping.tag;
node.data.hProperties = { className: [mapping.className] };
});
};
}
/**
* rehype plugin: wrap a lone image paragraph in <figure>, with the alt text as
* figcaption when present. Images sitting next to prose stay inline — only
* paragraphs that are just an image get figured.
*/
export function rehypeFigure() {
return (tree: any) => {
visit(tree, 'element', (node: any, index: number, parent: any) => {
if (node.tagName !== 'p' || index === null || !parent) return;
const img = node.children.find(
(child: any) => child.type === 'element' && child.tagName === 'img',
);
if (!img) return;
const leftovers = node.children.filter(
(child: any) =>
child !== img && !(child.type === 'text' && !child.value.trim()),
);
if (leftovers.length > 0) return;
const caption = img.properties && img.properties.alt;
parent.children[index] = {
type: 'element',
tagName: 'figure',
properties: { className: ['prose-figure'] },
children: [
img,
...(caption
? [
{
type: 'element',
tagName: 'figcaption',
properties: { className: ['prose-figcaption'] },
children: [{ type: 'text', value: caption }],
},
]
: []),
],
};
});
};
}
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkDirective)
.use(remarkDirectiveTags)
.use(remarkRehype)
.use(rehypeFigure)
// Dark theme on a dark pre (background comes from the blog CSS module —
// keepBackground: false keeps the pipeline's HTML free of hardcoded hex).
.use(rehypePrettyCode, { theme: 'vesper', keepBackground: false })
.use(rehypeStringify);
/** Markdown → HTML string (tables, directives, figures, shiki spans). */
export async function renderMarkdown(md: string) {
return String(await processor.process(md));
}
import {getAllContentData, getAllContentIds, getContentData} from '@/lib/content'
export function getPostsData() {
return getAllContentData('posts', true)
}
export function getAllPostIds() {
return getAllContentIds("posts")
}
export async function getPostData(id) {
return getContentData("posts", id)
}
...@@ -13,6 +13,8 @@ ...@@ -13,6 +13,8 @@
"platform:build": "vercel build", "platform:build": "vercel build",
"test": "jest -c jest.config.js", "test": "jest -c jest.config.js",
"test:watch": "jest -c jest.config.js --watch", "test:watch": "jest -c jest.config.js --watch",
"post": "node scripts/new-post.mjs",
"post:image": "node scripts/add-image.mjs",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
...@@ -40,10 +42,17 @@ ...@@ -40,10 +42,17 @@
"react-masonry-css": "^1.0.16", "react-masonry-css": "^1.0.16",
"react-player": "^2.14.1", "react-player": "^2.14.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"remark": "^14.0.0", "rehype-pretty-code": "^0.14.5",
"remark-html": "^15.0.0", "rehype-stringify": "^10.0.1",
"remark-directive": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"shiki": "^4.4.3",
"swiper": "^11.2.6", "swiper": "^11.2.6",
"tailwindcss": "^4.2.1" "tailwindcss": "^4.2.1",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.60.0", "@playwright/test": "1.60.0",
......
#!/usr/bin/env node
// Copy an image into a post's asset folder and print the markdown snippet.
// Usage: node scripts/add-image.mjs <slug> <file> [alt] (or `yarn post:image <slug> <file>`)
import fs from 'node:fs';
import path from 'node:path';
const [slug, file, alt = ''] = process.argv.slice(2);
if (!slug || !file) {
console.error('Usage: node scripts/add-image.mjs <slug> <file> [alt]');
process.exit(1);
}
const src = path.resolve(process.cwd(), file);
if (!fs.existsSync(src) || !fs.statSync(src).isFile()) {
console.error(`No such file: ${src}`);
process.exit(1);
}
const dir = path.join(process.cwd(), 'public/images/blog', slug);
fs.mkdirSync(dir, { recursive: true });
const name = path.basename(src);
fs.copyFileSync(src, path.join(dir, name));
console.log(`![${alt}](/images/blog/${slug}/${name})`);
#!/usr/bin/env node
// Scaffold a blog post. Usage: node scripts/new-post.mjs <slug> (or `yarn post <slug>`).
import fs from 'node:fs';
import path from 'node:path';
const slug = process.argv[2];
if (!slug || /[^a-z0-9-]/i.test(slug)) {
console.error('Usage: node scripts/new-post.mjs <slug> (kebab-case: letters, digits, dashes)');
process.exit(1);
}
const date = new Date().toISOString().slice(0, 10);
const file = path.join(process.cwd(), 'content/blog', `${date}-${slug}.md`);
if (fs.existsSync(file)) {
console.error(`Already exists: ${file}`);
process.exit(1);
}
const title = slug
.split('-')
.map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
.join(' ');
fs.writeFileSync(
file,
`---
title: ${title}
date: '${date}'
slug: ${slug}
draft: true
description: ''
tags: []
---
Start typing here.
`,
);
console.log(file);
...@@ -22,7 +22,7 @@ const ROUTES: string[] = [ ...@@ -22,7 +22,7 @@ const ROUTES: string[] = [
'/talks', '/talks',
'/poesie', '/poesie',
'/poesie/berg', '/poesie/berg',
'/post/hello-world', '/blog/hello-world-again',
'/hydra', '/hydra',
'/hydra/adenora', '/hydra/adenora',
'/starry-nights', '/starry-nights',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment