This release transforms PING into a sophisticated newspaper-style digital publication with enhanced readability and professional presentation. Major Features: - New FeaturedPostHero component with full-width newspaper design - Completely redesigned homepage with responsive newspaper grid layout - Enhanced PostCard component with refined typography and spacing - Improved mobile-first responsive design (mobile → tablet → desktop → 2XL) - Archive section with multi-column layout for deeper content discovery Technical Improvements: - Enhanced blog post validation and error handling in lib/blog.ts - Better date handling and normalization for scheduled posts - Improved Dockerfile with correct content volume mount paths - Fixed port configuration (3025 throughout stack) - Updated Tailwind config with refined typography and newspaper aesthetics - Added getFeaturedPost() function for hero selection UI/UX Enhancements: - Professional newspaper-style borders and dividers - Improved dark mode styling throughout - Better content hierarchy and visual flow - Enhanced author bylines and metadata presentation - Refined color palette with newspaper sophistication Documentation: - Added DESIGN_BRIEF_NEWSPAPER_LAYOUT.md detailing design principles - Added TESTING_RESULTS_25_POSTS.md with test scenarios This release establishes PING as a premium publication platform for AI orchestration and contextual intelligence thought leadership. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
170 lines
5.2 KiB
TypeScript
170 lines
5.2 KiB
TypeScript
import fs from 'fs'
|
|
import path from 'path'
|
|
import matter from 'gray-matter'
|
|
import { BlogPost, BlogMeta } from '@/types/blog'
|
|
|
|
const postsDirectory = path.join(process.cwd(), 'content/posts')
|
|
const scheduledDirectory = path.join(process.cwd(), 'content/scheduled')
|
|
|
|
// Helper function to check if a post should be published
|
|
function shouldPublishPost(post: BlogMeta): boolean {
|
|
// Don't publish draft posts
|
|
if (post.draft === true) return false
|
|
|
|
// Require at least one valid date field
|
|
const publishDate = post.publishDate || post.date
|
|
if (!publishDate) return false
|
|
|
|
const publishDateTime = new Date(publishDate)
|
|
if (isNaN(publishDateTime.getTime())) return false
|
|
|
|
const now = new Date()
|
|
return publishDateTime <= now
|
|
}
|
|
|
|
// Helper function to recursively read markdown files from a directory
|
|
function readMarkdownFiles(directory: string, basePath: string = ''): BlogPost[] {
|
|
if (!fs.existsSync(directory)) {
|
|
return []
|
|
}
|
|
|
|
const items = fs.readdirSync(directory)
|
|
const posts: BlogPost[] = []
|
|
|
|
items.forEach((item) => {
|
|
const fullPath = path.join(directory, item)
|
|
const stat = fs.statSync(fullPath)
|
|
|
|
if (stat.isDirectory()) {
|
|
// Recursively read subdirectories
|
|
const subPosts = readMarkdownFiles(fullPath, path.join(basePath, item))
|
|
posts.push(...subPosts)
|
|
} else if (item.endsWith('.md')) {
|
|
try {
|
|
// Read markdown file as string
|
|
const fileContents = fs.readFileSync(fullPath, 'utf8')
|
|
|
|
// Use gray-matter to parse the post metadata section
|
|
const matterResult = matter(fileContents)
|
|
const meta = matterResult.data as Partial<BlogMeta>
|
|
|
|
// Create slug from filename (remove .md extension)
|
|
const slug = item.replace(/\.md$/, '')
|
|
|
|
// Calculate reading time (average 200 words per minute)
|
|
const wordCount = matterResult.content.split(/\s+/).length
|
|
const readingTime = Math.ceil(wordCount / 200)
|
|
|
|
// Basic validation and normalization
|
|
const rawDate = meta.date || meta.publishDate
|
|
if (!rawDate) {
|
|
console.warn(`Skipping '${fullPath}' - missing date/publishDate in frontmatter`)
|
|
return
|
|
}
|
|
const dateObj = new Date(rawDate as string)
|
|
if (isNaN(dateObj.getTime())) {
|
|
console.warn(`Skipping '${fullPath}' - invalid date value: ${rawDate}`)
|
|
return
|
|
}
|
|
|
|
if (!meta.title || !meta.description) {
|
|
console.warn(`Skipping '${fullPath}' - missing required title/description in frontmatter`)
|
|
return
|
|
}
|
|
|
|
const normalizedDate = meta.date ? (meta.date as string) : dateObj.toISOString().split('T')[0]
|
|
const normalizedPublishDate = meta.publishDate ? new Date(meta.publishDate as string).toISOString() : undefined
|
|
|
|
const post: BlogPost = {
|
|
slug,
|
|
title: meta.title,
|
|
description: meta.description,
|
|
content: matterResult.content,
|
|
date: normalizedDate,
|
|
publishDate: normalizedPublishDate,
|
|
draft: meta.draft === true,
|
|
author: meta.author || { name: 'CHORUS Team' },
|
|
tags: Array.isArray(meta.tags) ? (meta.tags as string[]) : [],
|
|
featured: meta.featured === true,
|
|
coverImage: meta.coverImage,
|
|
readingTime,
|
|
}
|
|
|
|
posts.push(post)
|
|
} catch (error) {
|
|
console.error(`Error reading markdown file ${fullPath}:`, error)
|
|
}
|
|
}
|
|
})
|
|
|
|
return posts
|
|
}
|
|
|
|
export function getSortedPostsData(): BlogPost[] {
|
|
// Read posts from both published and scheduled directories
|
|
const publishedPosts = readMarkdownFiles(postsDirectory)
|
|
const scheduledPosts = readMarkdownFiles(scheduledDirectory)
|
|
|
|
// Combine all posts
|
|
const allPosts = [...publishedPosts, ...scheduledPosts]
|
|
|
|
// Filter posts that should be published
|
|
const publishablePosts = allPosts.filter(post => shouldPublishPost(post))
|
|
|
|
// Sort posts by date (newest first)
|
|
return publishablePosts.sort((a, b) => {
|
|
const dateA = new Date(a.date)
|
|
const dateB = new Date(b.date)
|
|
return dateB.getTime() - dateA.getTime()
|
|
})
|
|
}
|
|
|
|
export function getAllPostSlugs() {
|
|
// Get all publishable posts and extract their slugs
|
|
const posts = getSortedPostsData()
|
|
return posts.map((post) => {
|
|
return {
|
|
params: {
|
|
slug: post.slug,
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
export function getPostData(slug: string): BlogPost | null {
|
|
// Find the post in our sorted posts data
|
|
const posts = getSortedPostsData()
|
|
const post = posts.find(p => p.slug === slug)
|
|
|
|
if (!post) {
|
|
console.error(`Post with slug '${slug}' not found or not yet publishable`)
|
|
return null
|
|
}
|
|
|
|
return post
|
|
}
|
|
|
|
export function getFeaturedPost(): BlogPost | null {
|
|
const allPosts = getSortedPostsData()
|
|
// Return the most recent post as the featured post
|
|
return allPosts.length > 0 ? allPosts[0] : null
|
|
}
|
|
|
|
export function getPostsByTag(tag: string): BlogPost[] {
|
|
const allPosts = getSortedPostsData()
|
|
return allPosts.filter(post =>
|
|
post.tags.some(t => t.toLowerCase() === tag.toLowerCase())
|
|
)
|
|
}
|
|
|
|
export function getAllTags(): string[] {
|
|
const allPosts = getSortedPostsData()
|
|
const tags = new Set<string>()
|
|
|
|
allPosts.forEach(post => {
|
|
post.tags.forEach(tag => tags.add(tag))
|
|
})
|
|
|
|
return Array.from(tags).sort()
|
|
}
|