import fs from 'fs' import path from 'path' import matter from 'gray-matter' import { remark } from 'remark' import remarkParse from 'remark-parse' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import remarkRehype from 'remark-rehype' import rehypeHighlight from 'rehype-highlight' import rehypeKatex from 'rehype-katex' import rehypeReact from 'rehype-react' import { cache, cloneElement, isValidElement, type ReactElement, Fragment, type ReactNode, } from 'react' import { jsx, jsxs } from 'react/jsx-runtime' import { rehypeCodeMeta } from '@/lib/rehype-code-meta' import { CodeWrapper } from '@/components/content/CodeWrapper' import { ImageWrapper } from '@/components/content/ImageWrapper' import { ExternalLink } from '@/components/ui/ExternalLink' import readingTime from 'reading-time' import { rehypeUnwrapImages } from '@/lib/rehype-unwrap-images' import { rehypeMermaidSsg } from '@/lib/rehype-mermaid-ssg' import { getTextContent, stripLeadingMatch } from '@/lib/react-node-text' import { BlockquoteWrapper } from '@/components/content/BlockquoteWrapper' import type { NoteType } from '@/types/Note.type' import { rehypeMark } from '@/lib/rehype-mark' import { rehypeDefinition } from '@/lib/rehype-definition' import { slugify } from '@/utils/slug' import { PostMeta } from '@/types/Post.type' import Link from 'next/link' const postsDirectory = path.join(process.cwd(), 'content/posts') const pagesDirectory = path.join(process.cwd(), 'content/pages') const includeDrafts = process.env.NODE_ENV !== 'production' type TocItem = { text: string; id: string; level: number } function readMarkdownFile(directory: string, slug: string) { return matter(fs.readFileSync(path.join(directory, `${slug}.md`), 'utf8')) } /** * Validates post frontmatter and normalizes dates to ISO strings. * Throws at build time with the offending file and field instead of rendering a broken post. */ function parsePostFrontmatter(slug: string, data: Record) { const fail = (message: string): never => { throw new Error(`Invalid frontmatter in content/posts/${slug}.md: ${message}`) } const requireString = (key: string) => { const value = data[key] return typeof value === 'string' && value.trim() !== '' ? value : fail(`"${key}" is required and must be a non-empty string`) } const optionalString = (key: string) => { const value = data[key] return value === undefined || typeof value === 'string' ? value : fail(`"${key}" must be a string`) } // YAML parses bare dates (2024-08-04) into Date objects, anything with a time stays a string const optionalDate = (key: string) => { const value = data[key] if (value === undefined) return undefined const date = value instanceof Date ? value : new Date(String(value)) return Number.isNaN(date.getTime()) ? fail(`"${key}" is not a valid date`) : date.toISOString() } const tags = data.tags ?? [] if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== 'string')) { fail('"tags" must be a list of strings') } return { title: requireString('title'), description: requireString('description'), date: optionalDate('date') ?? fail('"date" is required'), updated: optionalDate('updated'), category: requireString('category'), tags: tags as string[], math: data.math === true, mermaid: data.mermaid === true, draft: data.draft === true, coverImage: optionalString('coverImage'), coverImageAlt: optionalString('coverImageAlt'), } } export function getPostMeta(slug: string): PostMeta { const { data, content } = readMarkdownFile(postsDirectory, slug) return { slug, ...parsePostFrontmatter(slug, data), readingTimeText: readingTime(content).text, } } export function getAllPostsMeta(): PostMeta[] { return fs .readdirSync(postsDirectory) .filter((f) => f.endsWith('.md')) .map((f) => getPostMeta(f.replace(/\.md$/, ''))) .filter((post) => includeDrafts || !post.draft) .toSorted((a, b) => (a.date < b.date ? 1 : -1)) } export function getAllPostSlugs(): string[] { return getAllPostsMeta().map((post) => post.slug) } export function getNextAndPreviousPosts(slug: string): { next: PostMeta | null previous: PostMeta | null } { const posts = getAllPostsMeta() const index = posts.findIndex((post) => post.slug === slug) const previous = index < posts.length - 1 ? posts[index + 1] : null const next = index > 0 ? posts[index - 1] : null return { next, previous } } // Cached so generateMetadata and the page share one render per request/build export const getPostBySlug = cache(async (slug: string) => { const meta = getPostMeta(slug) const { content } = readMarkdownFile(postsDirectory, slug) return { meta, ...(await renderMarkdown(content, meta)) } }) /** Loads a standalone page (about, projects, ...) from content/pages. */ export const getPageBySlug = cache(async (slug: string) => { const { data, content } = readMarkdownFile(pagesDirectory, slug) const meta = { title: String(data.title), description: String(data.description) } return { meta, ...(await renderMarkdown(content, data)) } }) async function renderMarkdown( content: string, options: { math?: boolean; mermaid?: boolean }, ): Promise<{ content: ReactNode; tableOfContents: TocItem[] }> { let processor = remark().use(remarkParse).use(remarkGfm) if (options.math) { processor = processor.use(remarkMath) } processor = processor .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeHighlight) .use(rehypeCodeMeta) .use(rehypeMark) .use(rehypeDefinition) if (options.math) { processor = processor.use(rehypeKatex) } if (options.mermaid) { processor = processor.use(rehypeMermaidSsg) } processor = processor.use(rehypeUnwrapImages).use(rehypeReact, { Fragment, jsx, jsxs, components: { pre: ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => { const language = typeof props['data-language'] === 'string' ? props['data-language'] : undefined return {children} }, img: ({ src, alt, title }: { src: string; alt: string; title?: string }) => ( ), a: ({ href, children, ...props }: { href?: string; children: ReactNode }) => { if (href?.startsWith('http')) return {children} // Only internal routes go through next/link; anchors, mailto:, footnotes etc. stay plain if (href?.startsWith('/')) return {children} return ( {children} ) }, blockquote: ({ children }: { children: ReactNode }) => { const textContent = getTextContent(children).trimStart() const type = textContent.match(/^\[!(\w+)\]/)?.[1]?.toLowerCase() as NoteType | undefined return ( {stripLeadingMatch(children, /^\[!(\w+)\]/)} ) }, }, }) const file = await processor.process(content) const rawContentNode = file.result as ReactNode const tableOfContents: TocItem[] = [] const seenIds = new Map() function withHeadingIds(node: ReactNode): ReactNode { if (!isValidElement(node)) return node const element = node as ReactElement<{ children?: ReactNode; id?: string }> if (element.type === 'h2' || element.type === 'h3') { const text = getTextContent(element.props.children ?? []) let id = slugify(text) const seenCount = seenIds.get(id) ?? 0 seenIds.set(id, seenCount + 1) if (seenCount > 0) id = `${id}-${seenCount}` const level = element.type === 'h2' ? 2 : 3 tableOfContents.push({ text, id, level }) return cloneElement(element, { id }) } return node } const rawChildren = isValidElement(rawContentNode) ? (rawContentNode.props as { children?: ReactNode }).children : rawContentNode const childrenArray = Array.isArray(rawChildren) ? rawChildren : [rawChildren] const processedChildren = childrenArray.map(withHeadingIds) const processedContent = isValidElement(rawContentNode) ? cloneElement(rawContentNode, {}, processedChildren) : rawContentNode return { content: processedContent, tableOfContents } }