fix: normalize post metadata and tag helpers
This commit is contained in:
130
lib/posts.ts
130
lib/posts.ts
@@ -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))
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user