content.ts

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
17/**
18 * Represents the metadata of a content file.
19 */
20export type Metadata = {
21	// eslint-disable-next-line @typescript-eslint/no-explicit-any
22	[key: string]: any
23}
24
25/**
26 * Represents a content file with its metadata, slug, and content.
27 */
28export type Content = {
29	metadata: Metadata
30	slug: string
31	content: string
32	path: string
33}
34
35/**
36 * Parses the frontmatter of a content file.
37 */
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
171/**
172 * Valid file extensions for content files
173 */
174const VALID_EXTENSIONS = [
175	'.json',
176	'.yaml',
177	'.yml',
178	'.md',
179	'.mdx',
180	'.html',
181	'.txt',
182]
183
184/**
185 * Gets all content files in a directory.
186 */
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
200/**
201 * Reads and parses a content file.
202 */
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
221/**
222 * Gets data from all content files in a directory.
223 */
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
255/**
256 * Cached version of getContent for better performance in Next.js
257 */
258export const getCachedContent = cache((dirName: string): Content[] => {
259	const contentPath = path.join(process.cwd(), 'content', dirName)
260	return getContentData(contentPath)
261})
262
263/**
264 * Gets all content files from a specific directory.
265 */
266export function getContent(dirName: string): Content[] {
267	const contentPath = path.join(process.cwd(), 'content', dirName)
268	return getContentData(contentPath)
269}
270
271/**
272 * Gets all content file paths in the 'content' directory and its subdirectories.
273 */
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
307/**
308 * Gets the content of a specific file.
309 */
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
317/**
318 * Gets all possible content paths for Next.js static generation
319 */
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
327/**
328 * Sorts content by date (newest first)
329 */
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
338/**
339 * Formats a date string.
340 */
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
369/**
370 * Converts a file extension to a MIME type.
371 */
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