From 21be810a6827504dad287bdd6a633e457b619e65 Mon Sep 17 00:00:00 2001 From: Krishna Ayyalasomayajula Date: Wed, 3 Jun 2026 10:38:19 -0500 Subject: [PATCH] fix: normalize post metadata and tag helpers --- lib/posts.ts | 130 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 99 insertions(+), 31 deletions(-) diff --git a/lib/posts.ts b/lib/posts.ts index 7535277..273657f 100644 --- a/lib/posts.ts +++ b/lib/posts.ts @@ -18,10 +18,65 @@ export interface 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, + 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((f) => f.endsWith('.mdx') || f.endsWith('.md')) + return files.filter((file) => file.endsWith('.mdx')).sort((a, b) => a.localeCompare(b)) } catch { return [] } @@ -33,22 +88,11 @@ export const getPosts = cache(async (): Promise => { files.map(async (file) => { const filePath = path.join(postsDirectory, file) const raw = await fs.promises.readFile(filePath, 'utf-8') - const { data } = matter(raw) - const slug = file.replace(/\.(mdx|md)$/, '') - // Compute reading time from content (everything after frontmatter) - const content = raw.split(/---\n*\n*/).slice(2).join('\n') - const readingTime = Math.max(1, Math.ceil(content.split(/\s+/).length / 200)) + const { data, content } = matter(raw) + const slug = file.replace(/\.mdx$/, '') + const readingTime = getReadingTime(content) - 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, - } + return normalizePostData(slug, data, readingTime) }) ).then((posts) => posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) @@ -57,29 +101,53 @@ export const getPosts = cache(async (): Promise => { export const getPost = async (slug: string): Promise => { 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 const filePath = path.join(postsDirectory, file) const raw = await fs.promises.readFile(filePath, 'utf8') - const { data } = matter(raw) + const { data, content } = matter(raw) - const contentForReadingTime = raw.replace(/^---[\s\S]*?---\s*?/, '') - 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 + 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 => { + const posts = await getPosts() + const tags = new Map() + + posts.forEach((post) => { + const postTagSlugs = new Set() + + 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 => { + const posts = await getPosts() + const normalizedTagSlug = tagToSlug(tagSlug) + + return posts.filter((post) => post.tags.some((tag) => tagToSlug(tag) === normalizedTagSlug)) +})