Skip to content

Commit 5da7c59

Browse files
committed
perf: slim list pages static props payloads
1 parent 0ec3426 commit 5da7c59

11 files changed

Lines changed: 216 additions & 89 deletions

File tree

__mocks__/fileMock.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = { src: '/file-stub', height: 1, width: 1 };
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { render, screen } from '@testing-library/react';
2+
3+
import { PostCategory, PostMetadata, SnippetMetadata } from '@/lib/types';
4+
import IndexPage, {
5+
getStaticProps as getIndexStaticProps,
6+
} from '@/pages/index';
7+
import SnippetsPage, {
8+
getStaticProps as getSnippetsStaticProps,
9+
} from '@/pages/snippets';
10+
11+
const makePost = (slug: string, featured = false): PostMetadata => ({
12+
data: {
13+
slug,
14+
title: `${slug} title`,
15+
heading: `${slug} heading`,
16+
description: `${slug} description`,
17+
createDate: 1700000000000,
18+
keywords: [],
19+
updateDate: null,
20+
readTime: '3 min read',
21+
featured,
22+
categories: featured ? [PostCategory.JS] : [PostCategory.AdvancedReact],
23+
},
24+
});
25+
26+
const makeSnippet = (slug: string): SnippetMetadata => ({
27+
data: {
28+
slug,
29+
title: `${slug} title`,
30+
heading: `${slug} heading`,
31+
description: `${slug} description`,
32+
createDate: 1700000000000,
33+
keywords: [],
34+
updateDate: null,
35+
},
36+
});
37+
38+
describe('list pages static props payload', () => {
39+
test('homepage props contain post metadata only, no content', async () => {
40+
const result = await getIndexStaticProps();
41+
42+
if (!('props' in result)) {
43+
throw new Error('expected getStaticProps to return props');
44+
}
45+
46+
const { featuredPosts, otherPosts, advancedReactPosts } = result.props;
47+
const allPosts = [...featuredPosts, ...otherPosts, ...advancedReactPosts];
48+
49+
expect(allPosts.length).toBeGreaterThan(0);
50+
for (const post of allPosts) {
51+
expect(post).not.toHaveProperty('content');
52+
expect(post.data.slug).toEqual(expect.any(String));
53+
expect(post.data.heading).toEqual(expect.any(String));
54+
}
55+
});
56+
57+
test('snippets page props contain snippet metadata only, no content', async () => {
58+
const result = await getSnippetsStaticProps();
59+
60+
if (!('props' in result)) {
61+
throw new Error('expected getStaticProps to return props');
62+
}
63+
64+
const { snippets, jsonLd } = result.props;
65+
66+
expect(snippets.length).toBeGreaterThan(0);
67+
for (const snippet of snippets) {
68+
expect(snippet).not.toHaveProperty('content');
69+
expect(snippet.data.heading).toEqual(expect.any(String));
70+
}
71+
expect(jsonLd).toHaveProperty('@type', 'CollectionPage');
72+
});
73+
});
74+
75+
describe('list pages render from metadata-only props', () => {
76+
test('homepage renders post headings', () => {
77+
render(
78+
<IndexPage
79+
featuredPosts={[makePost('feat-one', true)]}
80+
otherPosts={[makePost('other-one')]}
81+
advancedReactPosts={[makePost('react-one')]}
82+
/>,
83+
);
84+
85+
expect(
86+
screen.getByRole('heading', { name: 'Serhii Shramko' }),
87+
).toBeInTheDocument();
88+
expect(screen.getByText('feat-one heading')).toBeInTheDocument();
89+
expect(screen.getByText('react-one heading')).toBeInTheDocument();
90+
expect(screen.getByText('other-one heading')).toBeInTheDocument();
91+
});
92+
93+
test('snippets page renders snippet cards', () => {
94+
render(
95+
<SnippetsPage snippets={[makeSnippet('use-debounce')]} jsonLd={{}} />,
96+
);
97+
98+
expect(
99+
screen.getByRole('heading', { name: 'Code Snippets' }),
100+
).toBeInTheDocument();
101+
expect(screen.getByText('use-debounce heading')).toBeInTheDocument();
102+
});
103+
});

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const config = {
2222
'^@/pages/(.*)$': '<rootDir>/pages/$1',
2323
'^@/lib/(.*)$': '<rootDir>/lib/$1',
2424
'^lib/prisma$': '<rootDir>/lib/prisma',
25+
'\\.(webp|png|jpe?g|gif|svg|ico)$': '<rootDir>/__mocks__/fileMock.js',
2526
'\\.(css)$': '<rootDir>/__mocks__/styleMock.js',
2627
},
2728
testPathIgnorePatterns: ['/node_modules/', '/.next/', '/__tests__/helpers/'],

lib/posts/api.test.ts

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import matter from 'gray-matter';
55
import {
66
filterPostsByCategory,
77
getPostBySlug,
8-
getPosts,
98
getPostsCategories,
109
getPostsMetadata,
1110
getPostSlugs,
@@ -201,43 +200,6 @@ describe('Posts API', () => {
201200
});
202201
});
203202

204-
describe('getPosts', () => {
205-
it('should ignore non-markdown files in the posts directory', async () => {
206-
(readdir as jest.Mock).mockResolvedValueOnce([
207-
'a.md',
208-
'.DS_Store',
209-
'README.txt',
210-
'b.md',
211-
]);
212-
(readFile as jest.Mock).mockResolvedValue('content');
213-
214-
const posts = await getPosts();
215-
216-
expect(posts).toHaveLength(2);
217-
expect(posts.map((p) => p.data.slug)).toEqual(['a', 'b']);
218-
});
219-
220-
it('should return both data and content for each post', async () => {
221-
(readdir as jest.Mock).mockResolvedValueOnce(['only.md']);
222-
(readFile as jest.Mock).mockResolvedValueOnce('raw markdown');
223-
224-
const [post] = await getPosts();
225-
226-
expect(post).toEqual(
227-
expect.objectContaining({
228-
data: expect.objectContaining({ slug: 'only' }),
229-
content: 'Test content',
230-
}),
231-
);
232-
});
233-
234-
it('should return empty array when directory is empty', async () => {
235-
(readdir as jest.Mock).mockResolvedValueOnce([]);
236-
237-
await expect(getPosts()).resolves.toEqual([]);
238-
});
239-
});
240-
241203
describe('getPostsMetadata', () => {
242204
it('should return metadata-only entries (no content field)', async () => {
243205
(readdir as jest.Mock).mockResolvedValueOnce(['a.md', 'b.md']);

lib/posts/api.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,6 @@ export async function getPostBySlug(slug?: string): Promise<Post> {
7777
}
7878
}
7979

80-
export async function getPosts(): Promise<Post[]> {
81-
const fileNames = await readdir(POSTS_DIRECTORY);
82-
const markdownFiles = fileNames.filter((fileName) =>
83-
fileName.endsWith('.md'),
84-
);
85-
86-
const postPromises = markdownFiles
87-
.map(extractMarkdownSlug)
88-
.map(getPostBySlug);
89-
90-
return Promise.all(postPromises);
91-
}
92-
9380
export async function getPostsMetadata(): Promise<PostMetadata[]> {
9481
const fileNames = await readdir(POSTS_DIRECTORY);
9582
const markdownFiles = fileNames.filter((fileName) =>

lib/posts/utils.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
import { Post, PostCategory, PostMetadata, Snippet } from '@/lib/types';
1+
import {
2+
Post,
3+
PostCategory,
4+
PostMetadata,
5+
Snippet,
6+
SnippetMetadata,
7+
} from '@/lib/types';
28

39
export const sortByBirthtime = (
4-
first: Post | PostMetadata | Snippet,
5-
second: Post | PostMetadata | Snippet,
10+
first: Post | PostMetadata | Snippet | SnippetMetadata,
11+
second: Post | PostMetadata | Snippet | SnippetMetadata,
612
) => second.data.createDate - first.data.createDate;
713

814
export const filterByFeatured = (post: Post | PostMetadata) =>

lib/snippets/api.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {
2+
getSnippetBySlug,
3+
getSnippetSlugs,
4+
getSnippetsMetadata,
5+
} from '@/lib/snippets/api';
6+
7+
describe('Snippets API', () => {
8+
describe('getSnippetsMetadata', () => {
9+
it('returns one metadata entry per snippet file, without content', async () => {
10+
const slugs = await getSnippetSlugs();
11+
const snippets = await getSnippetsMetadata();
12+
13+
expect(snippets).toHaveLength(slugs.length);
14+
expect(snippets.length).toBeGreaterThan(0);
15+
16+
for (const snippet of snippets) {
17+
expect(snippet).not.toHaveProperty('content');
18+
expect(snippet.data.slug).toEqual(expect.any(String));
19+
expect(snippet.data.heading).toEqual(expect.any(String));
20+
expect(snippet.data.createDate).toEqual(expect.any(Number));
21+
}
22+
});
23+
});
24+
25+
describe('getSnippetBySlug', () => {
26+
it('returns both data and content for a real snippet', async () => {
27+
const [slug] = await getSnippetSlugs();
28+
const snippet = await getSnippetBySlug(slug);
29+
30+
expect(snippet.data.slug).toBe(slug);
31+
expect(snippet.content).toEqual(expect.any(String));
32+
expect(snippet.content.length).toBeGreaterThan(0);
33+
});
34+
35+
it('throws when slug is missing', async () => {
36+
await expect(getSnippetBySlug()).rejects.toThrow(
37+
'getSnippetBySlug: slug is required',
38+
);
39+
});
40+
41+
it('throws a wrapped error for a nonexistent slug', async () => {
42+
await expect(
43+
getSnippetBySlug('definitely-not-a-real-snippet'),
44+
).rejects.toThrow('ENOENT');
45+
});
46+
});
47+
});

lib/snippets/api.ts

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,53 +3,71 @@ import { join } from 'path';
33

44
import matter from 'gray-matter';
55

6-
import { Snippet } from '@/lib/types';
6+
import { Snippet, SnippetMetadata } from '@/lib/types';
77
import { extractMarkdownSlug } from '@/lib/utils';
88

99
const SNIPPETS_DIRECTORY = join(process.cwd(), '_snippets');
1010

11+
async function readSnippetFile(slug: string) {
12+
const fullPath = join(SNIPPETS_DIRECTORY, `${slug}.md`);
13+
const fileContents = await readFile(fullPath, 'utf8');
14+
15+
return matter(fileContents);
16+
}
17+
18+
function buildSnippetData(
19+
slug: string,
20+
matterResult: matter.GrayMatterFile<string>,
21+
): Snippet['data'] {
22+
const {
23+
data: { title, heading, description, createDate, updateDate, keywords },
24+
} = matterResult;
25+
26+
return {
27+
slug,
28+
title,
29+
heading,
30+
description,
31+
keywords,
32+
createDate: Date.parse(createDate),
33+
updateDate: updateDate ? Date.parse(updateDate) : null,
34+
};
35+
}
36+
1137
export async function getSnippetBySlug(slug?: string): Promise<Snippet> {
1238
if (!slug) {
13-
throw new Error('getPostBySlug: slug is required');
39+
throw new Error('getSnippetBySlug: slug is required');
1440
}
1541

1642
try {
17-
const fullPath = join(SNIPPETS_DIRECTORY, `${slug}.md`);
18-
const fileContents = await readFile(fullPath, 'utf8');
19-
20-
const {
21-
data: { title, heading, description, createDate, updateDate, keywords },
22-
content,
23-
} = matter(fileContents);
43+
const matterResult = await readSnippetFile(slug);
2444

2545
return {
26-
data: {
27-
slug,
28-
title,
29-
heading,
30-
description,
31-
keywords,
32-
createDate: Date.parse(createDate),
33-
updateDate: updateDate ? Date.parse(updateDate) : null,
34-
},
35-
content,
46+
data: buildSnippetData(slug, matterResult),
47+
content: matterResult.content,
3648
};
3749
} catch (err) {
3850
throw new Error(String(err), { cause: err });
3951
}
4052
}
4153

42-
export async function getSnippets(): Promise<Snippet[]> {
54+
async function getSnippetMetadataBySlug(
55+
slug: string,
56+
): Promise<SnippetMetadata> {
57+
const matterResult = await readSnippetFile(slug);
58+
59+
return { data: buildSnippetData(slug, matterResult) };
60+
}
61+
62+
export async function getSnippetsMetadata(): Promise<SnippetMetadata[]> {
4363
const fileNames = await readdir(SNIPPETS_DIRECTORY);
4464
const markdownFiles = fileNames.filter((fileName) =>
4565
fileName.endsWith('.md'),
4666
);
4767

48-
const snippetPromises = markdownFiles
49-
.map(extractMarkdownSlug)
50-
.map(getSnippetBySlug);
51-
52-
return Promise.all(snippetPromises);
68+
return Promise.all(
69+
markdownFiles.map(extractMarkdownSlug).map(getSnippetMetadataBySlug),
70+
);
5371
}
5472

5573
export async function getSnippetSlugs(): Promise<string[]> {

lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export type Snippet = {
6666
content: string;
6767
};
6868

69+
export type SnippetMetadata = Omit<Snippet, 'content'>;
70+
6971
type LinkedinCompany = {
7072
name: string;
7173
url: string;

pages/index.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import useSWR from 'swr';
99

1010
import { BlogPostSquarePreview } from '@/components/blog-post-square-preview';
1111
import { fetcher } from '@/lib/fetcher';
12-
import { getPosts } from '@/lib/posts/api';
12+
import { getPostsMetadata } from '@/lib/posts/api';
1313
import {
1414
filterByAdvanceReact,
1515
filterByFeatured,
@@ -18,17 +18,17 @@ import {
1818
} from '@/lib/posts/utils';
1919
import { Routes } from '@/lib/routes';
2020
import { generateWebSiteSchema } from '@/lib/schema';
21-
import { Post } from '@/lib/types';
21+
import { PostMetadata } from '@/lib/types';
2222
import { generateGradient } from '@/lib/utils';
2323
import type { AllViewsResponse } from '@/pages/api/views';
2424

2525
import smile from '../public/static/images/smile.webp';
2626
import tongue from '../public/static/images/tongue.webp';
2727

2828
interface IndexPageProps {
29-
featuredPosts: Post[];
30-
otherPosts: Post[];
31-
advancedReactPosts: Post[];
29+
featuredPosts: PostMetadata[];
30+
otherPosts: PostMetadata[];
31+
advancedReactPosts: PostMetadata[];
3232
}
3333

3434
function IndexPage(props: IndexPageProps) {
@@ -250,7 +250,7 @@ function IndexPage(props: IndexPageProps) {
250250
export async function getStaticProps(): Promise<
251251
GetStaticPropsResult<IndexPageProps>
252252
> {
253-
const posts = await getPosts();
253+
const posts = await getPostsMetadata();
254254
const otherPosts = posts
255255
.filter(filterByNotFeatured)
256256
.sort(sortByBirthtime)

0 commit comments

Comments
 (0)