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' 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 interface PostMeta { slug: string title: string description: string author: string date: string categories: string[] tags: string[] math: boolean mermaid: boolean readingTimeText: string coverImage?: string coverImageAlt?: string } 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 } = matter(fs.readFileSync(fullPath, 'utf8')) return Object.assign({ slug }, data as Omit) }) .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 {children} }, img: ({ src, alt }: { src: string; alt: string }) => , a: ({ href, children }: { href: string; children: ReactNode }) => href?.startsWith('http') ? ( {children} ) : ( {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 readingTimeText = readingTime(content).text const tableOfContents: { text: string; id: string; level: number }[] = [] 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 { meta: { ...(data as Omit), readingTimeText }, content: processedContent, tableOfContents, } } export function getPostsByCategory(category: string): PostMeta[] { return getAllPostsMeta().filter((post) => post.categories.includes(category)) } export function getPostsByTag(tag: string): PostMeta[] { return getAllPostsMeta().filter((post) => post.tags.includes(tag)) }