167 lines
5.7 KiB
TypeScript
167 lines
5.7 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 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 { 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'
|
|
import { ExternalLinkIcon } from 'lucide-react'
|
|
|
|
const postsDirectory = path.join(process.cwd(), 'content/posts')
|
|
|
|
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 function getNextAndPreviousPosts(slug: string): {
|
|
next: PostMeta | null
|
|
previous: PostMeta | null
|
|
} {
|
|
const posts = getAllPostsMeta()
|
|
const index = posts.findIndex((post) => post.slug === slug)
|
|
const next = index < posts.length - 1 ? posts[index + 1] : null
|
|
const previous = index > 0 ? posts[index - 1] : null
|
|
return { next, previous }
|
|
}
|
|
|
|
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(remarkGfm)
|
|
|
|
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}
|
|
<ExternalLinkIcon className="inline-block w-3 h-3 ml-1" />
|
|
</ExternalLink>
|
|
) : (
|
|
<Link href={href}>{children}</Link>
|
|
),
|
|
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,
|
|
}
|
|
}
|