Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata> {
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.`;

Expand Down Expand Up @@ -47,7 +47,7 @@ export async function generateMetadata(): Promise<Metadata> {
};
}

function generateListSchema(posts: PostData[], siteUrl: string) {
function generateListSchema(posts: PostMetadata[], siteUrl: string) {
return {
'@context': 'https://schema.org',
'@type': 'ItemList',
Expand Down Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions components/blog/articles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('');
Expand Down
4 changes: 2 additions & 2 deletions components/blog/post-item.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,7 +12,7 @@ function PostItem({
imageClassName,
}: {
showDescription?: boolean;
post: PostData;
post: PostMetadata;
showCategories?: boolean;
useThumbnail?: boolean;
imageClassName?: string;
Expand Down
148 changes: 140 additions & 8 deletions lib/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,31 +39,154 @@ export interface PostData {
unpublished?: boolean;
}

// Metadata-only type (excludes heavy fields)
export type PostMetadata = Omit<PostData, 'content' | 'toc' | 'word_count' | 'reading_time'>;

const postsDirectory = path.join(process.cwd(), '_blog');

let cachedPosts: PostData[] | null = null;
let cachedMetadata: PostMetadata[] | null = null;
const postCache = new Map<string, PostData>();
const filenameLookup = new Map<string, string>();
const legacyFilenameLookup = new Map<string, string>();
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<PostData> {
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<PostData[]> {
Expand Down Expand Up @@ -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;
}
Loading