This blog starts with ordinary .md files in posts/, but those files feed several parts of the site: the blog index, statically generated article pages, local search, the command palette, RSS, the sitemap, and social metadata. The center of that flow is lib/posts.js, which gives every consumer a consistent post shape.
For the search experience built on top of this index, see Building a Terminal Command Palette in React. An earlier overview of build-time rendering is available in How This Site Pre-renders Its Blog.
Reading Frontmatter and Markdown
lib/posts.js locates content relative to the running project:
const postsDirectory = path.join(process.cwd(), 'posts')
getSortedPostsData reads filenames ending in .md, removes the extension to form each post ID, and passes the file text to gray-matter. The parsed data object supplies frontmatter, while matterResult.content contains the Markdown body. The returned list includes a fallback title, an empty fallback description, normalized tags, and calculated reading time before being sorted newest-first by date.
The process-level _allPostsCache avoids rereading every file when multiple build-time consumers ask for the same list. A separate _postContentCache stores fully processed articles by ID. These caches last only for the current Node process; they are not a remote content cache or an invalidation service.
For a single article, getPostData(id) reads ${id}.md, parses it with gray-matter, and converts the body to HTML:
const processedContent = await remark()
.use(html)
.process(matterResult.content);
The result combines the generated contentHtml with normalized metadata and reading time. Rendering remains simple because pages/posts/[id].js can insert that trusted build-time HTML into the article container.
Normalizing Tags and Reading Time
Existing posts do not all express tags in the same shape. normalizePostTags accepts an array, a single value, or no value. It splits every value on commas, trims whitespace, removes empty entries, and uses a Set to remove duplicates:
const values = Array.isArray(tags) ? tags : tags == null ? [] : [tags];
That means both a legacy line such as - nextjs, performance and separate YAML list items become the same array for cards, post headers, and search. New posts can use one tag per line without requiring downstream changes.
lib/readingTime.js counts whitespace-separated words, divides by 200, and rounds displayed minutes up with a minimum of one minute for non-empty text. It returns the display text plus raw minutes, milliseconds, and word count. lib/posts.js computes this from the Markdown body, not frontmatter.
Static Index and Static Article Routes
pages/blog.js uses getStaticProps to pass getSortedPostsData() into the page at build time. The newest post is featured unless frontmatter marks another post with featured: true. Cards show the title, description, tags, date, and reading-time text.
Its terminal-styled search is local React state. A memoized call to searchPosts(allPostsData, query) filters the already loaded array, so typing does not make a request. Escape clears a non-empty query, a visible button does the same, and a polite live status reports the result count. lib/postSearch.js searches normalized title, description, and tag text and requires all query terms to match.
Article pages are also statically generated. getAllPostIds supplies every filename-derived path to getStaticPaths, and fallback: false limits the route to those known IDs. For each path, getStaticProps loads the article and finds its neighbors in the newest-first index:
export async function getStaticPaths() {
const paths = getAllPostIds()
return { paths, fallback: false }
}
The resulting page includes newer and older article links plus a route back to /blog.
Enhancing Plain HTML After Rendering
Remark emits plain HTML here, without heading IDs or interactive controls. pages/posts/[id].js enhances that output in a useEffect after rendering.
For every h2 and h3, slugify lowercases the text, replaces non-word runs with hyphens, and trims surrounding hyphens. A seen set prevents duplicate IDs by appending -2 until the candidate is unused. The effect then appends a # anchor with an accessible label that names the heading.
Every pre receives a $ copy button unless one already exists. Clicking it reads the nested code element when present, writes through navigator.clipboard, changes the label to copied ✓, and restores the original label after two seconds. Failed or unavailable clipboard operations are ignored. Guards against existing anchors and buttons keep the enhancement from duplicating controls if the effect runs again.
RSS, Sitemap, and Search API
The same normalized index serves non-page routes. pages/rss.xml.js builds RSS items with title, canonical post URL, publication date, and description. It XML-escapes text fields, writes the response in getServerSideProps, and sets cache headers.
pages/sitemap.xml.js combines shared SITEMAP_PATHS from lib/nav.js with every /posts/[id] URL. Static routes use the generation time as lastmod; posts use their frontmatter dates. Like RSS, the sitemap is generated server-side and cached by shared caches.
pages/api/posts-index.js provides a lightweight JSON projection for terminal-style search. It can apply the shared matcher to a q query, although the command palette currently fetches the full projection once and filters it in the browser.
Dynamic Social Images and Verification
Post pages pass title, description, canonical path, optional metaTitle, and optional socialImage to components/Seo.js. When frontmatter does not provide an absolute HTTP image URL, Seo constructs /api/og parameters instead. pages/api/og.tsx runs at the edge and returns a 1200-by-630 ImageResponse using the requested title and subtitle. This lets a post omit placeholder image frontmatter while still receiving Open Graph and Twitter image tags.
The repository’s Playwright tests cover the pipeline’s public edges. tests/e2e/pages.spec.js opens a post from the blog and confirms article content renders. tests/e2e/features.spec.js checks slugged heading anchors. tests/e2e/seo.spec.js verifies that RSS contains items, the sitemap serves routes, and the OG endpoint returns an image. These checks do not replace unit tests for parsing, but they confirm that Markdown content reaches the routes readers and crawlers use.