Files
public-blog/lib/posts.ts

154 lines
4.3 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { cache } from 'react'
const postsDirectory = path.join(process.cwd(), 'content/posts')
export interface PostMeta {
slug: string
title: string
date: string
excerpt: string
tags: string[]
author: string | null
coverImage: string | null
readingTime: number
}
export type Post = PostMeta
export interface TagMeta {
slug: string
label: string
count: number
}
const normalizeString = (value: unknown, fallback: string): string => {
if (typeof value === 'string') return value
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString()
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return fallback
}
const normalizeOptionalString = (value: unknown): string | null => {
if (typeof value !== 'string') return null
const normalized = value.trim()
return normalized.length > 0 ? normalized : null
}
const normalizeTags = (value: unknown): string[] => {
const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []
return values
.map((tag) => normalizeString(tag, '').trim())
.filter((tag) => tag.length > 0)
}
export const normalizePostData = (
slug: string,
data: Record<string, unknown>,
readingTime: number
): PostMeta => ({
slug,
title: normalizeString(data.title, slug),
date: normalizeString(data.date, 'Unknown'),
excerpt: normalizeString(data.excerpt, ''),
tags: normalizeTags(data.tags),
author: normalizeOptionalString(data.author),
coverImage: normalizeOptionalString(data.coverImage),
readingTime,
})
export const tagToSlug = (tag: string): string =>
tag
.normalize('NFKD')
.toLowerCase()
.replace(/[\u0300-\u036f]/g, '')
.replace(/\+/g, ' plus ')
.replace(/#/g, ' sharp ')
.replace(/[\\/]+/g, ' ')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/-{2,}/g, '-')
.replace(/^-|-$/g, '')
const getMdxFiles = cache(async () => {
try {
const files = await fs.promises.readdir(postsDirectory)
return files.filter((file) => file.endsWith('.mdx')).sort((a, b) => a.localeCompare(b))
} catch {
return []
}
})
export const getPosts = cache(async (): Promise<PostMeta[]> => {
const files = await getMdxFiles()
return Promise.all(
files.map(async (file) => {
const filePath = path.join(postsDirectory, file)
const raw = await fs.promises.readFile(filePath, 'utf-8')
const { data, content } = matter(raw)
const slug = file.replace(/\.mdx$/, '')
const readingTime = getReadingTime(content)
return normalizePostData(slug, data, readingTime)
})
).then((posts) =>
posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
)
})
export const getPost = async (slug: string): Promise<Post | null> => {
const files = await getMdxFiles()
const file = files.find((f) => f.replace(/\.mdx$/, '') === slug)
if (!file) return null
const filePath = path.join(postsDirectory, file)
const raw = await fs.promises.readFile(filePath, 'utf8')
const { data, content } = matter(raw)
return normalizePostData(slug, data, getReadingTime(content)) satisfies Post
}
export const getReadingTime = (content: string): number => {
const words = content.replace(/<[^>]*>/g, '').trim().split(/\s+/).filter(Boolean).length
return Math.max(1, Math.ceil(words / 200))
}
export const getAllTags = cache(async (): Promise<TagMeta[]> => {
const posts = await getPosts()
const tags = new Map<string, TagMeta>()
posts.forEach((post) => {
const postTagSlugs = new Set<string>()
post.tags.forEach((tag) => {
const slug = tagToSlug(tag)
if (!slug || postTagSlugs.has(slug)) return
postTagSlugs.add(slug)
const existing = tags.get(slug)
if (existing) {
existing.count += 1
if (tag.localeCompare(existing.label) < 0) existing.label = tag
return
}
tags.set(slug, { slug, label: tag, count: 1 })
})
})
return Array.from(tags.values()).sort(
(a, b) => a.label.localeCompare(b.label) || a.slug.localeCompare(b.slug)
)
})
export const getPostsByTag = cache(async (tagSlug: string): Promise<PostMeta[]> => {
const posts = await getPosts()
const normalizedTagSlug = tagToSlug(tagSlug)
return posts.filter((post) => post.tags.some((tag) => tagToSlug(tag) === normalizedTagSlug))
})