1import fs from 'fs'
2import path from 'path'
3import yaml from 'js-yaml'
4import matter from 'gray-matter'
5import { cache } from 'react'
6import remarkMath from 'remark-math'
7import rehypeKatex from 'rehype-katex'
8import 'highlight.js/styles/github-dark.css'
9import hljs from 'highlight.js'
10import { unified } from 'unified'
11import remarkParse from 'remark-parse'
12import remarkRehype from 'remark-rehype'
13import rehypeRaw from 'rehype-raw'
14import rehypeStringify from 'rehype-stringify'
15import { unescapeHtml } from './utils'
16
1719
20export type Metadata = {
21 // eslint-disable-next-line @typescript-eslint/no-explicit-any
22 [key: string]: any
23}
24
2527
28export type Content = {
29 metadata: Metadata
30 slug: string
31 content: string
32 path: string
33}
34
3537
38function parseFrontmatter(
39 fileContent: string,
40 fileType: string,
41): { metadata: Metadata; content: string; rawContent: string } {
42 switch (fileType) {
43 case 'json':
44 try {
45 const parsed = JSON.parse(fileContent)
46 return {
47 metadata: parsed.metadata || {},
48 content: parsed.content || '',
49 rawContent: fileContent,
50 }
51 } catch (error) {
52 console.error('Error parsing JSON:', error)
53 return { metadata: {}, content: fileContent, rawContent: fileContent }
54 }
55 case 'yaml':
56 case 'yml':
57 try {
58 const parsed = yaml.load(fileContent) as {
59 metadata?: Metadata
60 content?: string
61 }
62 return {
63 metadata: parsed.metadata || {},
64 content: parsed.content || '',
65 rawContent: fileContent,
66 }
67 } catch (error) {
68 console.error('Error parsing YAML:', error)
69 return { metadata: {}, content: fileContent, rawContent: fileContent }
70 }
71 case 'markdown':
72 case 'md':
73 case 'mdx':
74 const { data, content } = matter(fileContent)
75 const htmlProcessor = unified()
76 .use(remarkParse)
77 .use(remarkRehype, { allowDangerousHtml: true })
78 .use(rehypeRaw)
79 .use(rehypeStringify)
80 .use(remarkMath)
81 .use(rehypeKatex)
82
83 let htmlContent = htmlProcessor.processSync(content).toString()
84
85 // Add IDs to headings
86 htmlContent = htmlContent.replace(
87 /<(h[1-6])>(.*?)<\/h[1-6]>/g,
88 (match, tag, content) => {
89 const id = slugify(content)
90 return `<${tag} id="${id}">${content}</${tag}>`
91 },
92 )
93
94 // Replace video tags with custom player
95 htmlContent = htmlContent.replace(
96 /<video([^>]*)src="([^"]*)"([^>]*)>/g,
97 (_, before, src) => `
98 <div class="custom-video-player">
99 <div data-video-src="${src}"></div>
100 </div>
101 `,
102 )
103
104 // Replace img tags with custom viewer
105 htmlContent = htmlContent.replace(
106 /<img([^>]*)src="([^"]*)"([^>]*)alt="([^"]*)"([^>]*)>/g,
107 (_, before, src, middle, alt) => `
108 <div class="custom-image-viewer">
109 <div data-image-src="${src}" data-image-alt="${alt}"></div>
110 </div>
111 `,
112 )
113
114 // Process code blocks with syntax highlighting
115 htmlContent = htmlContent.replace(
116 /<pre><code( class="language-(\w+)")?>([\s\S]*?)<\/code><\/pre>/g,
117 (_, classAttr, language, code) => {
118 // First decode standard HTML entities, then decode any remaining special entities
119 const decodedCode = unescapeHtml(code)
120
121 const highlightedCode =
122 language ?
123 hljs.highlight(decodedCode, { language }).value
124 : hljs.highlightAuto(decodedCode).value
125
126 return `
127 <div class="code-wrapper relative group">
128 <pre><code class="hljs code${language ? ` language-${language}` : ''}">${highlightedCode}</code></pre>
129 <button class="copy-button absolute top-2 right-2 p-2 bg-gray-700 text-gray-300 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 hover:bg-gray-600">
130 <span class="copy-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></span>
131 <span class="check-icon hidden"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
132 </button>
133 </div>
134 `
135 },
136 )
137
138 return {
139 metadata: data,
140 content: htmlContent,
141 rawContent: content,
142 }
143 case 'html':
144 const htmlMetadataRegex = /<!--\s*METADATA\s*([\s\S]*?)\s*-->/
145 const htmlMatch = htmlMetadataRegex.exec(fileContent)
146 if (htmlMatch) {
147 try {
148 const metadata = yaml.load(htmlMatch[1]) as Metadata
149 const content = fileContent.replace(htmlMetadataRegex, '').trim()
150 return { metadata, content, rawContent: fileContent }
151 } catch (error) {
152 console.error('Error parsing HTML metadata:', error)
153 }
154 }
155 return {
156 metadata: {},
157 content: fileContent.trim(),
158 rawContent: fileContent,
159 }
160 case 'text':
161 case 'txt':
162 default:
163 return {
164 metadata: {},
165 content: fileContent.trim(),
166 rawContent: fileContent,
167 }
168 }
169}
170
171173
174const VALID_EXTENSIONS = [
175 '.json',
176 '.yaml',
177 '.yml',
178 '.md',
179 '.mdx',
180 '.html',
181 '.txt',
182]
183
184186
187function getContentFilesInDir(dir: string): string[] {
188 try {
189 return fs
190 .readdirSync(dir)
191 .filter((file) =>
192 VALID_EXTENSIONS.includes(path.extname(file).toLowerCase()),
193 )
194 } catch (error) {
195 console.error(`Error reading directory ${dir}:`, error)
196 return []
197 }
198}
199
200202
203export function readContentFile(filePath: string): {
204 metadata: Metadata
205 content: string
206} {
207 try {
208 const fileExists = fs.existsSync(filePath)
209 if (!fileExists) {
210 return { metadata: {}, content: '' }
211 }
212 const rawContent = fs.readFileSync(filePath, 'utf-8')
213 const fileType = path.extname(filePath).slice(1).toLowerCase()
214 return parseFrontmatter(rawContent, fileType)
215 } catch (error) {
216 console.error(`Error reading file ${filePath}:`, error)
217 return { metadata: {}, content: '' }
218 }
219}
220
221223
224function getContentData(dir: string): Content[] {
225 try {
226 const contentFiles = getContentFilesInDir(dir)
227 return contentFiles
228 .map((file) => {
229 const fullPath = path.join(dir, file)
230 const { metadata, content } = readContentFile(fullPath)
231 const slug = path.basename(file, path.extname(file))
232
233 // Only include published content unless in development
234 if (
235 process.env.NODE_ENV !== 'development' &&
236 metadata.published === false
237 ) {
238 return null
239 }
240
241 return {
242 metadata,
243 slug,
244 content,
245 path: fullPath,
246 }
247 })
248 .filter((item): item is Content => item !== null)
249 } catch (error) {
250 console.error(`Error getting content data from ${dir}:`, error)
251 return []
252 }
253}
254
255257
258export const getCachedContent = cache((dirName: string): Content[] => {
259 const contentPath = path.join(process.cwd(), 'content', dirName)
260 return getContentData(contentPath)
261})
262
263265
266export function getContent(dirName: string): Content[] {
267 const contentPath = path.join(process.cwd(), 'content', dirName)
268 return getContentData(contentPath)
269}
270
271273
274export function getContentFiles(dir: string = ''): string[] {
275 const contentDir = path.join(process.cwd(), 'content', dir)
276
277 const walk = (currentDir: string): string[] => {
278 try {
279 let files: string[] = []
280 const items = fs.readdirSync(currentDir)
281
282 for (const item of items) {
283 const fullPath = path.join(currentDir, item)
284 const stat = fs.statSync(fullPath)
285
286 if (stat.isDirectory()) {
287 files = files.concat(walk(fullPath))
288 } else if (
289 stat.isFile() &&
290 VALID_EXTENSIONS.includes(path.extname(item).toLowerCase())
291 ) {
292 const relativePath = path.relative(contentDir, fullPath)
293 files.push(relativePath.replace(/\\/g, '/'))
294 }
295 }
296
297 return files
298 } catch (error) {
299 console.error(`Error walking directory ${currentDir}:`, error)
300 return []
301 }
302 }
303
304 return walk(contentDir)
305}
306
307309
310export const getCachedContentItem = cache(
311 (dirName: string, slug: string): Content | undefined => {
312 const files = getContent(dirName)
313 return files.find((file) => file.slug === slug)
314 },
315)
316
317319
320export function generateContentPaths(dirName: string) {
321 const files = getContentFiles(dirName)
322 return files.map((file) => ({
323 slug: path.basename(file, path.extname(file)),
324 }))
325}
326
327329
330export function sortContentByDate(content: Content[]): Content[] {
331 return [...content].sort((a, b) => {
332 const dateA = a.metadata.date ? new Date(a.metadata.date).getTime() : 0
333 const dateB = b.metadata.date ? new Date(b.metadata.date).getTime() : 0
334 return dateB - dateA
335 })
336}
337
338340
341export function formatDate(date: string, includeRelative = false): string {
342 const currentDate = new Date()
343 const targetDate = new Date(date.includes('T') ? date : `${date}T00:00:00`)
344
345 const diffTime = currentDate.getTime() - targetDate.getTime()
346 const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
347
348 let formattedDate = ''
349
350 if (diffDays > 365) {
351 formattedDate = `${Math.floor(diffDays / 365)}y ago`
352 } else if (diffDays > 30) {
353 formattedDate = `${Math.floor(diffDays / 30)}mo ago`
354 } else if (diffDays > 0) {
355 formattedDate = `${diffDays}d ago`
356 } else {
357 formattedDate = 'Today'
358 }
359
360 const fullDate = targetDate.toLocaleString('en-us', {
361 month: 'long',
362 day: 'numeric',
363 year: 'numeric',
364 })
365
366 return includeRelative ? `${fullDate} (${formattedDate})` : fullDate
367}
368
369371
372export function extToType(ext: string): string {
373 const mimeTypes: { [key: string]: string } = {
374 json: 'application/json',
375 yaml: 'application/x-yaml',
376 yml: 'application/x-yaml',
377 md: 'text/markdown',
378 mdx: 'text/markdown',
379 html: 'text/html',
380 txt: 'text/plain',
381 }
382
383 return mimeTypes[ext.toLowerCase()] || 'text/plain'
384}
385
386function slugify(text: string): string {
387 return text
388 .toLowerCase()
389 .replace(/[^a-z0-9]+/g, '-')
390 .replace(/(^-|-$)/g, '')
391}
392