export type BlogPost = { id?: string path?: string title?: string description?: string published?: string updated?: string | null image?: string | null draft?: boolean pinned?: boolean tags?: string[] series?: string | null seriesOrder?: number | null difficulty?: string | null body?: unknown } export type ArchiveMonth = { month: string label: string posts: BlogPost[] } export type ArchiveYear = { year: string months: ArchiveMonth[] } export type SeriesSummary = { name: string path: string count: number posts: BlogPost[] latestPost: BlogPost latestTime: number tags: string[] } export function isVisiblePost(post: BlogPost): boolean { return !post.draft } export function sortPostsByPublished(posts: BlogPost[]): BlogPost[] { return [...posts].sort((a, b) => getPostTime(b.published) - getPostTime(a.published)) } export function sortPostsForSeries(posts: BlogPost[]): BlogPost[] { return [...posts].sort((a, b) => { const orderA = typeof a.seriesOrder === 'number' ? a.seriesOrder : Number.MAX_SAFE_INTEGER const orderB = typeof b.seriesOrder === 'number' ? b.seriesOrder : Number.MAX_SAFE_INTEGER if (orderA !== orderB) return orderA - orderB return getPostTime(a.published) - getPostTime(b.published) }) } export function getArchiveGroups(posts: BlogPost[]): ArchiveYear[] { const years = new Map>() for (const post of sortPostsByPublished(posts)) { const date = getPostDate(post.published) if (!date) continue const year = String(date.getFullYear()) const month = String(date.getMonth() + 1).padStart(2, '0') if (!years.has(year)) { years.set(year, new Map()) } const months = years.get(year)! if (!months.has(month)) { months.set(month, []) } months.get(month)!.push(post) } return [...years.entries()] .sort(([a], [b]) => Number(b) - Number(a)) .map(([year, months]) => ({ year, months: [...months.entries()] .sort(([a], [b]) => Number(b) - Number(a)) .map(([month, monthPosts]) => ({ month, label: `${Number(month)} 月`, posts: monthPosts, })), })) } export function getSeriesSummaries(posts: BlogPost[]): SeriesSummary[] { const groups = new Map() for (const post of posts) { const name = normalizeSeriesName(post.series) if (!name) continue if (!groups.has(name)) { groups.set(name, []) } groups.get(name)!.push(post) } return [...groups.entries()] .map(([name, items]) => { const seriesPosts = sortPostsForSeries(items) const latestPost = sortPostsByPublished(seriesPosts)[0] const tags = [...new Set(seriesPosts.flatMap(post => post.tags || []))].slice(0, 6) return { name, path: getSeriesPath(name), count: seriesPosts.length, posts: seriesPosts, latestPost, latestTime: Math.max(...seriesPosts.map(post => getPostTime(post.updated || post.published))), tags, } }) .sort((a, b) => b.latestTime - a.latestTime) } export function normalizeSeriesName(series?: string | null): string { return typeof series === 'string' ? series.trim() : '' } export function getSeriesPath(name: string): string { return `/series/${encodeURIComponent(name)}` } export function decodeRouteParam(value: string): string { try { return decodeURIComponent(value) } catch { return value } } export function formatPostDate(date?: string | null): string { if (!date) return '' return new Date(date).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', }) } export function getWordCount(post: BlogPost): number { const text = JSON.stringify(post.body || '').replace(/<[^>]*>/g, '') const chinese = text.match(/[一-龥]/g) || [] const english = text.match(/[a-zA-Z]+/g) || [] return chinese.length + english.length } export function getReadTime(post: BlogPost): number { return Math.max(1, Math.ceil(getWordCount(post) / 300)) } function getPostDate(date?: string | null): Date | null { if (!date) return null const value = new Date(date) if (Number.isNaN(value.getTime())) return null return value } function getPostTime(date?: string | null): number { return getPostDate(date)?.getTime() || 0 }