blog.hangerthem.com/lib/posts.tsx
2026-08-02 22:39:12 +02:00

162 lines
5.5 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { remark } from 'remark'
import remarkParse from 'remark-parse'
import gfm 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 { cloneElement, isValidElement, type ReactElement, Fragment, type ReactNode } from 'react'
import { jsx, jsxs } from 'react/jsx-runtime'
import { rehypeCodeMeta } from './rehype-code-meta'
import { CodeWrapper } from '@/components/common/CodeWrapper'
import { ImageWrapper } from '@/components/common/ImageWrapper'
import { ExternalLink } from '@/components/common/ExternalLink'
import readingTime from 'reading-time'
import { rehypeUnwrapImages } from './rehype-unwrap-images'
import { rehypeMermaidSsg } from './rehype-mermaid-ssg'
import { stripLeadingMatch } from './react-node-text'
import { BlockquoteWrapper } from '@/components/common/BlockquoteWrapper'
import type { NoteType } from '@/types/Note.type'
import { rehypeMark } from './rehype-mark'
import { rehypeDefinition } from './rehype-definition'
import { slugify } from '@/utils/slug'
import { PostMeta } from '@/types/Post.type'
const postsDirectory = path.join(process.cwd(), 'content/posts')
function getTextContent(node: ReactNode): string {
if (typeof node === 'string') return node
if (typeof node === 'number') return String(node)
if (Array.isArray(node)) return node.map(getTextContent).join('')
if (node && typeof node === 'object' && 'props' in node) {
const props = node.props as { children?: ReactNode }
return getTextContent(props.children)
}
return ''
}
export function getAllPostSlugs(): string[] {
return fs
.readdirSync(postsDirectory)
.filter((f) => f.endsWith('.md'))
.map((f) => f.replace(/\.md$/, ''))
}
export function getAllPostsMeta(): PostMeta[] {
return getAllPostSlugs()
.map((slug) => {
const fullPath = path.join(postsDirectory, `${slug}.md`)
const { data, content } = matter(fs.readFileSync(fullPath, 'utf8'))
const readingTimeText = readingTime(content).text
return Object.assign({ slug }, {
...data,
readingTimeText,
} as Omit<PostMeta, 'slug'>)
})
.toSorted((a, b) => (a.date < b.date ? 1 : -1))
}
export async function getPostBySlug(slug: string) {
const fullPath = path.join(postsDirectory, `${slug}.md`)
const { data, content } = matter(fs.readFileSync(fullPath, 'utf8'))
let processor = remark().use(remarkParse).use(gfm)
if (data.math) {
processor = processor.use(remarkMath)
}
processor = processor
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeHighlight)
.use(rehypeCodeMeta)
.use(rehypeMark)
.use(rehypeDefinition)
if (data.math) {
processor = processor.use(rehypeKatex, { output: 'mathml' })
}
if (data.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 <CodeWrapper language={language}>{children}</CodeWrapper>
},
img: ({ src, alt }: { src: string; alt: string }) => <ImageWrapper src={src} alt={alt} />,
a: ({ href, children }: { href: string; children: ReactNode }) =>
href?.startsWith('http') ? (
<ExternalLink href={href}>{children}</ExternalLink>
) : (
<a href={href}>{children}</a>
),
blockquote: ({ children }: { children: ReactNode }) => {
const textContent = getTextContent(children).trimStart()
const type = textContent.match(/^\[!(\w+)\]/)?.[1]?.toLowerCase() as NoteType | undefined
return (
<BlockquoteWrapper type={type}>
{stripLeadingMatch(children, /^\[!(\w+)\]/)}
</BlockquoteWrapper>
)
},
},
})
const file = await processor.process(content)
const rawContentNode = file.result as ReactNode
const readingTimeText = readingTime(content).text
const tableOfContents: { text: string; id: string; level: number }[] = []
const seenIds = new Map<string, number>()
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 {
meta: { ...(data as Omit<PostMeta, 'slug'>), readingTimeText },
content: processedContent,
tableOfContents,
}
}