fix: normalize post metadata and tag helpers

This commit is contained in:
2026-06-03 10:38:19 -05:00
parent 19140b9df3
commit 21be810a68

View File

@@ -18,10 +18,65 @@ export interface PostMeta {
export type Post = PostMeta 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 () => { const getMdxFiles = cache(async () => {
try { try {
const files = await fs.promises.readdir(postsDirectory) const files = await fs.promises.readdir(postsDirectory)
return files.filter((f) => f.endsWith('.mdx') || f.endsWith('.md')) return files.filter((file) => file.endsWith('.mdx')).sort((a, b) => a.localeCompare(b))
} catch { } catch {
return [] return []
} }
@@ -33,22 +88,11 @@ export const getPosts = cache(async (): Promise<PostMeta[]> => {
files.map(async (file) => { files.map(async (file) => {
const filePath = path.join(postsDirectory, file) const filePath = path.join(postsDirectory, file)
const raw = await fs.promises.readFile(filePath, 'utf-8') const raw = await fs.promises.readFile(filePath, 'utf-8')
const { data } = matter(raw) const { data, content } = matter(raw)
const slug = file.replace(/\.(mdx|md)$/, '') const slug = file.replace(/\.mdx$/, '')
// Compute reading time from content (everything after frontmatter) const readingTime = getReadingTime(content)
const content = raw.split(/---\n*\n*/).slice(2).join('\n')
const readingTime = Math.max(1, Math.ceil(content.split(/\s+/).length / 200))
return { return normalizePostData(slug, data, readingTime)
slug,
title: data.title ?? slug,
date: data.date ?? 'Unknown',
excerpt: data.excerpt ?? '',
tags: data.tags ?? [],
author: data.author ?? null,
coverImage: data.coverImage ?? null,
readingTime,
}
}) })
).then((posts) => ).then((posts) =>
posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
@@ -57,29 +101,53 @@ export const getPosts = cache(async (): Promise<PostMeta[]> => {
export const getPost = async (slug: string): Promise<Post | null> => { export const getPost = async (slug: string): Promise<Post | null> => {
const files = await getMdxFiles() const files = await getMdxFiles()
const file = files.find((f) => f.replace(/\.(mdx|md)$/, '') === slug) const file = files.find((f) => f.replace(/\.mdx$/, '') === slug)
if (!file) return null if (!file) return null
const filePath = path.join(postsDirectory, file) const filePath = path.join(postsDirectory, file)
const raw = await fs.promises.readFile(filePath, 'utf8') const raw = await fs.promises.readFile(filePath, 'utf8')
const { data } = matter(raw) const { data, content } = matter(raw)
const contentForReadingTime = raw.replace(/^---[\s\S]*?---\s*?/, '') return normalizePostData(slug, data, getReadingTime(content)) satisfies Post
const readingTime = Math.max(1, Math.ceil(contentForReadingTime.split(/\s+/).filter(Boolean).length / 200))
return {
slug,
title: data.title ?? slug,
date: data.date ?? 'Unknown',
excerpt: data.excerpt ?? '',
tags: data.tags ?? [],
author: data.author ?? null,
coverImage: data.coverImage ?? null,
readingTime,
} satisfies Post
} }
export const getReadingTime = (content: string): number => { export const getReadingTime = (content: string): number => {
const words = content.replace(/<[^>]*>/g, '').trim().split(/\s+/).filter(Boolean).length const words = content.replace(/<[^>]*>/g, '').trim().split(/\s+/).filter(Boolean).length
return Math.max(1, Math.ceil(words / 200)) 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))
})