diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 21462d7..f5cb6e9 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -4,13 +4,13 @@ import { Metadata } from 'next'; import Script from 'next/script'; import React from 'react'; import OpenGraphImage from '../../public/images/og-image.png'; -import { getAllPosts, PostData } from '@/lib/posts'; +import { getAllPostsMetadata, PostMetadata } from '@/lib/posts'; const site_url = process.env.NEXT_PUBLIC_SITE_BASE_URL!; const name = 'AppFlowy Blog | In the Flow'; export async function generateMetadata(): Promise { - const posts = getAllPosts(); + const posts = getAllPostsMetadata(); const description = `Receive the latest updates and tips from AppFlowy. Offline mode, self-hosting, iOS and Android, Markdown editing, GPT-4, Claude, Llama, and team collaboration.`; @@ -47,7 +47,7 @@ export async function generateMetadata(): Promise { }; } -function generateListSchema(posts: PostData[], siteUrl: string) { +function generateListSchema(posts: PostMetadata[], siteUrl: string) { return { '@context': 'https://schema.org', '@type': 'ItemList', @@ -89,7 +89,7 @@ function generateListSchema(posts: PostData[], siteUrl: string) { } function Blog() { - const posts = getAllPosts(); + const posts = getAllPostsMetadata(); const listSchema = generateListSchema(posts, site_url); return ( diff --git a/components/blog/articles.tsx b/components/blog/articles.tsx index b4ae392..9df73cf 100644 --- a/components/blog/articles.tsx +++ b/components/blog/articles.tsx @@ -5,14 +5,14 @@ import { useSubscriber } from '@/components/blog/use-subscriber'; import Reddit from '@/components/icons/reddit'; import SearchIcon from '@/components/icons/search-icon'; import Twitter from '@/components/icons/twitter'; -import { PostData } from '@/lib/posts'; +import { PostMetadata } from '@/lib/posts'; import { cn } from '@/lib/utils'; import { Grid } from '@mui/material'; import { orderBy } from 'lodash-es'; import Link from 'next/link'; import React, { useMemo } from 'react'; -function Articles({ posts }: { posts: PostData[] }) { +function Articles({ posts }: { posts: PostMetadata[] }) { const { email, setEmail, handleSubscribe } = useSubscriber(); const [searchValue, setSearchValue] = React.useState(''); diff --git a/components/blog/post-item.tsx b/components/blog/post-item.tsx index 9d6ed5b..ce01d49 100644 --- a/components/blog/post-item.tsx +++ b/components/blog/post-item.tsx @@ -1,5 +1,5 @@ import { Badge } from '@/components/ui/badge'; -import { PostData } from '@/lib/posts'; +import { PostMetadata } from '@/lib/posts'; import { colorArrayTint, formatDate, stringToColor } from '@/lib/utils'; import Link from 'next/link'; import React from 'react'; @@ -12,7 +12,7 @@ function PostItem({ imageClassName, }: { showDescription?: boolean; - post: PostData; + post: PostMetadata; showCategories?: boolean; useThumbnail?: boolean; imageClassName?: string; diff --git a/lib/posts.ts b/lib/posts.ts index 1cb1bb2..798628e 100644 --- a/lib/posts.ts +++ b/lib/posts.ts @@ -39,31 +39,154 @@ export interface PostData { unpublished?: boolean; } +// Metadata-only type (excludes heavy fields) +export type PostMetadata = Omit; + const postsDirectory = path.join(process.cwd(), '_blog'); +let cachedPosts: PostData[] | null = null; +let cachedMetadata: PostMetadata[] | null = null; +const postCache = new Map(); +const filenameLookup = new Map(); +const legacyFilenameLookup = new Map(); +let currentSignature: string | null = null; + +const getSlugFromFilename = (fileName: string) => { + const [, , , ...rest] = fileName.replace(/\.mdx$/, '').split('-'); + return rest.join('-'); +}; + +const isMdxFile = (fileName: string) => fileName.endsWith('.mdx'); + +function ensureCacheFresh(): string[] { + const fileNames = fs.readdirSync(postsDirectory).filter(isMdxFile).sort(); + + const signature = fileNames + .map((fileName) => { + const { mtimeMs, size } = fs.statSync(path.join(postsDirectory, fileName)); + return `${fileName}:${mtimeMs}:${size}`; + }) + .join('|'); + + if (signature !== currentSignature) { + cachedPosts = null; + cachedMetadata = null; + postCache.clear(); + filenameLookup.clear(); + + fileNames.forEach((fileName) => { + const slug = getSlugFromFilename(fileName); + const baseName = fileName.replace(/\.mdx$/, ''); + + if (slug) { + filenameLookup.set(slug, fileName); + } + + // legacy: related_posts may reference the date-prefixed filename + legacyFilenameLookup.set(baseName, fileName); + }); + + currentSignature = signature; + } + + return fileNames; +} + +/** + * Get all blog posts with full content. + * Results are cached and invalidated when the underlying MDX files change. + */ export function getAllPosts(): PostData[] { - const fileNames = fs.readdirSync(postsDirectory); + const fileNames = ensureCacheFresh(); - return fileNames + if (cachedPosts) { + return cachedPosts; + } + cachedPosts = fileNames .map(getPostByFilename) .sort((a, b) => (new Date(a.date) > new Date(b.date) ? -1 : 1)) .filter((item) => !item.unpublished); + + return cachedPosts; +} + +/** + * Get all blog posts metadata only (fast operation) + * Skips content parsing, TOC generation, word counting, etc. + * Use this for listing pages where you don't need full content + */ +export function getAllPostsMetadata(): PostMetadata[] { + const fileNames = ensureCacheFresh(); + + if (cachedMetadata) { + return cachedMetadata; + } + + cachedMetadata = fileNames + .map((fileName) => { + const fullPath = path.join(postsDirectory, fileName); + const slug = getSlugFromFilename(fileName); + + const fileContents = fs.readFileSync(fullPath, 'utf8'); + const { data } = matter(fileContents); // Only parse frontmatter, skip content! + + return { + slug, + unpublished: data.unpublished || false, + pinned: data.pinned || 0, + title: data.title, + description: data.description, + author: data.author, + author_title: data.author_title, + author_url: data.author_url, + author_image_url: data.author_image_url, + categories: data.categories || [], + tags: data.tags || [], + date: data.date, + video_url: data.video_url, + og_image: data.image, + thumb_image: data.thumb, + last_modified: data.last_modified || data.date, + featured: data.featured || false, + comments: data.comments !== undefined ? data.comments : true, + canonical_url: data.canonical_url, + series: data.series, + cover_image: data.image, + related_posts: data.related, + }; + }) + .sort((a, b) => (new Date(a.date) > new Date(b.date) ? -1 : 1)) + .filter((item) => !item.unpublished); + + return cachedMetadata; } +/** + * Get a single blog post by slug. + * Returns a cached post when available; caches are reset if any MDX file + * changes to keep content fresh. + */ export async function getPostData(slug: string): Promise { - const fileNames = fs.readdirSync(postsDirectory); - const fileName = fileNames.find((name) => name.includes(slug))!; + ensureCacheFresh(); - if(!fileName) { + const fileName = filenameLookup.get(slug) ?? legacyFilenameLookup.get(slug); + + if (!fileName) { console.error(`[getPostData] Post not found for slug: "${slug}"`); console.error(`[getPostData] Posts directory: ${postsDirectory}`); - console.error(`[getPostData] Available files (${fileNames.length} total):`, fileNames.slice(0, 10)); - console.error(`[getPostData] Searched with: name.includes("${slug}")`); + console.error(`[getPostData] Available slugs: ${Array.from(filenameLookup.keys()).slice(0, 10).join(', ')}`); throw new Error('Post not found'); } - return getPostByFilename(fileName); + if (postCache.has(slug)) { + return postCache.get(slug)!; + } + + const post = getPostByFilename(fileName); + postCache.set(slug, post); + + return post; } export async function getRelatedPosts(post: PostData): Promise { @@ -136,3 +259,12 @@ export function getPostByFilename(fileName: string): PostData { related_posts: data.related, }; } + +export function clearPostsCache() { + cachedPosts = null; + cachedMetadata = null; + postCache.clear(); + filenameLookup.clear(); + legacyFilenameLookup.clear(); + currentSignature = null; +}