Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(blog): allow sorting blog posts through a options.sortPosts function hook #9840

Closed
wants to merge 16 commits into from
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getSourceToPermalink,
paginateBlogPosts,
type LinkifyParams,
sortBlogPosts,
} from '../blogUtils';
import type {BlogBrokenMarkdownLink, BlogContentPaths} from '../types';
import type {BlogPost} from '@docusaurus/plugin-content-blog';
Expand Down Expand Up @@ -272,3 +273,220 @@ describe('linkify', () => {
} as BlogBrokenMarkdownLink);
});
});

describe('blog sort', () => {
const pluginDir = 'blog-with-ref';

const descendingBlogPost: BlogPost[] = [
{
id: 'newest',
metadata: {
permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash',
source: path.posix.join(
'@site',
pluginDir,
'2018-12-14-Happy-First-Birthday-Slash.md',
),
title: 'Happy 1st Birthday Slash!',
description: `pattern name`,
date: new Date('2018-12-14'),
tags: [],
prevItem: {
permalink: '/blog/2019/01/01/date-matter',
title: 'date-matter',
},
hasTruncateMarker: false,
frontMatter: {},
authors: [],
unlisted: false,
formattedDate: '',
},
content: '',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

too verbose test code, create a helper that can accept partial blog post args and let us focus on what matters

Your test should be as readable as this (pseudo code), with the intent being super clear

it("sorts descending by default",() => {
  const blogPost2022 = createBlogPost({date: new Date('2022-12-14')});
	const blogPost2023 = createBlogPost({date: new Date('2023-12-14')});
	const blogPost2024 = createBlogPost({date: new Date('2024-12-14')});

  assert(sort([
	  blogPost2022,
	  blogPost2024,
	  blogPost2023,
  ])).equals([
		blogPost2024,
		blogPost2023,
		blogPost2022
	])
});

It's preferable if each test create its own inputs, instead of relying on a shared list of posts for all tests. As we add tests, this list will only grow over time and tests will become harder to understand and maintain

},
{
id: 'oldest',
metadata: {
permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash',
source: path.posix.join(
'@site',
pluginDir,
'2018-12-14-Happy-First-Birthday-Slash.md',
),
title: 'draft',
description: `pattern name`,
date: new Date('2017-12-14'),
tags: [],
prevItem: {
permalink: '/blog/2019/01/01/date-matter',
title: 'date-matter',
},
hasTruncateMarker: false,
frontMatter: {},
authors: [],
unlisted: false,
formattedDate: '',
},
content: '',
},
];

const ascendingBlogPost: BlogPost[] = [
{
id: 'oldest',
metadata: {
permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash',
source: path.posix.join(
'@site',
pluginDir,
'2018-12-14-Happy-First-Birthday-Slash.md',
),
title: 'draft',
description: `pattern name`,
date: new Date('2017-12-14'),
tags: [],
prevItem: {
permalink: '/blog/2019/01/01/date-matter',
title: 'date-matter',
},
hasTruncateMarker: false,
frontMatter: {},
authors: [],
unlisted: false,
formattedDate: '',
},
content: '',
},
{
id: 'newest',
metadata: {
permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash',
source: path.posix.join(
'@site',
pluginDir,
'2018-12-14-Happy-First-Birthday-Slash.md',
),
title: 'Happy 1st Birthday Slash!',
description: `pattern name`,
date: new Date('2018-12-14'),
tags: [],
prevItem: {
permalink: '/blog/2019/01/01/date-matter',
title: 'date-matter',
},
hasTruncateMarker: false,
frontMatter: {},
authors: [],
unlisted: false,
formattedDate: '',
},
content: '',
},
];

const BlogPostList: BlogPost[] = [
{
id: 'newest',
metadata: {
permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash',
source: path.posix.join(
'@site',
pluginDir,
'2018-12-14-Happy-First-Birthday-Slash.md',
),
title: 'Happy 1st Birthday Slash!',
description: `pattern name`,
date: new Date('2018-12-14'),
tags: [],
prevItem: {
permalink: '/blog/2019/01/01/date-matter',
title: 'date-matter',
},
hasTruncateMarker: false,
frontMatter: {},
authors: [],
unlisted: false,
formattedDate: '',
},
content: '',
},
{
id: 'oldest',
metadata: {
permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash',
source: path.posix.join(
'@site',
pluginDir,
'2018-12-14-Happy-First-Birthday-Slash.md',
),
title: 'draft',
description: `pattern name`,
date: new Date('2017-12-14'),
tags: [],
prevItem: {
permalink: '/blog/2019/01/01/date-matter',
title: 'date-matter',
},
hasTruncateMarker: false,
frontMatter: {},
authors: [],
unlisted: false,
formattedDate: '',
},
content: '',
},
];

it('sort blog posts by descending date no return', () => {
const sortedBlogPosts = sortBlogPosts({
blogPosts: BlogPostList,
sortPosts: 'descending',
});
expect(sortedBlogPosts).toEqual(BlogPostList);
});

it('sort blog posts by ascending date no return', () => {
const sortedBlogPosts = sortBlogPosts({
blogPosts: BlogPostList,
sortPosts: 'ascending',
});
expect(sortedBlogPosts).toEqual(BlogPostList);
});

it('sort blog posts by descending date with function return', () => {
const sortedBlogPosts = sortBlogPosts({
blogPosts: BlogPostList,
sortPosts: ({blogPosts}) =>
[...blogPosts].sort(
(a, b) => b.metadata.date.getTime() - a.metadata.date.getTime(),
),
});
expect(sortedBlogPosts).toEqual(descendingBlogPost);
});

it('sort blog posts by ascending date with function return', () => {
const sortedBlogPosts = sortBlogPosts({
blogPosts: BlogPostList,
sortPosts: ({blogPosts}) =>
[...blogPosts].sort(
(b, a) => b.metadata.date.getTime() - a.metadata.date.getTime(),
),
});
expect(sortedBlogPosts).toEqual(ascendingBlogPost);
});

it('sort blog posts with empty function', () => {
const sortedBlogPosts = sortBlogPosts({
blogPosts: BlogPostList,
sortPosts: () => {},
});
expect(sortedBlogPosts).toEqual(ascendingBlogPost);
});

it('sort blog posts with function return empty array', () => {
const sortedBlogPosts = sortBlogPosts({
blogPosts: BlogPostList,
sortPosts: () => [],
});
expect(sortedBlogPosts).toEqual(ascendingBlogPost);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
} as LoadContext,
{
path: 'invalid-blog-path',
sortPosts: 'descending',
routeBasePath: 'blog',
tagsBasePath: 'tags',
authorsMapPath: 'authors.yml',
Expand Down Expand Up @@ -128,6 +129,7 @@ describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
} as LoadContext,
{
path: 'blog',
sortPosts: 'descending',
routeBasePath: 'blog',
tagsBasePath: 'tags',
authorsMapPath: 'authors.yml',
Expand Down Expand Up @@ -171,6 +173,7 @@ describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
} as LoadContext,
{
path: 'blog',
sortPosts: 'descending',
routeBasePath: 'blog',
tagsBasePath: 'tags',
authorsMapPath: 'authors.yml',
Expand Down Expand Up @@ -224,6 +227,7 @@ describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
} as LoadContext,
{
path: 'blog',
sortPosts: 'descending',
routeBasePath: 'blog',
tagsBasePath: 'tags',
authorsMapPath: 'authors.yml',
Expand Down
72 changes: 65 additions & 7 deletions packages/docusaurus-plugin-content-blog/src/blogUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import type {
BlogPost,
BlogTags,
BlogPaginated,
Options,
SortBlogPostsFn,
SortPresets,
SortBlogPostsPreset,
} from '@docusaurus/plugin-content-blog';
import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types';

Expand Down Expand Up @@ -363,6 +367,62 @@ async function processBlogSourceFile(
};
}

const sortPresets: SortPresets = {
descending: ({blogPosts}) =>
blogPosts.sort(
(a, b) => b.metadata.date.getTime() - a.metadata.date.getTime(),
),
ascending: ({blogPosts}) =>
blogPosts.sort(
(a, b) => a.metadata.date.getTime() - b.metadata.date.getTime(),
),
};

interface SortBlogPostsOptions {
blogPosts: BlogPost[];
sortPosts: SortBlogPostsPreset | SortBlogPostsFn;
}

function getSortFunction(sortPosts: Options['sortPosts']): SortBlogPostsFn {
if (typeof sortPosts === 'function') {
return sortPosts;
}

if (typeof sortPosts === 'string') {
const presetFn = sortPresets[sortPosts];
if (!presetFn) {
throw new Error(
`sortPosts preset ${sortPosts} does not exist, valid presets are: ${Object.keys(
sortPresets,
).join(', ')}`,
);
}
return presetFn;
}

return () => {};
}

export function sortBlogPosts({
blogPosts,
sortPosts,
}: SortBlogPostsOptions): BlogPost[] {
const sortFunction = getSortFunction(sortPosts);
const sortedBlogPosts = sortFunction({blogPosts});

if (sortedBlogPosts !== undefined) {
if (sortedBlogPosts.length === 0) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why someone would return an empty array by mistakes.

Users are likely to either return undefined, or return a new sorted list of posts.

If we want to prevent mistakes, we should check that blogPosts.length === sortedBlogPosts.length.

We could check that the returned value is an array, because users might use JS in config files and return an unsafe value such as a number 🤷‍♂️


But I'm not sure we want to prevent that anyway, because sortPosts could also be used as a temporary workaround to filter/modify posts until we come up with a better API for it.
It's not the primary use case of this API, and it's a bit awkward to do so.
See my comment here:
#9846 (comment)

Maybe sortPosts is not the API we should extend?

logger.warn(
`Sorting function returned an empty array. Reverting to the original list to prevent issues.`,
);
return blogPosts;
}
return sortedBlogPosts;
}

return blogPosts;
}

export async function generateBlogPosts(
contentPaths: BlogContentPaths,
context: LoadContext,
Expand Down Expand Up @@ -405,14 +465,12 @@ export async function generateBlogPosts(
await Promise.all(blogSourceFiles.map(doProcessBlogSourceFile))
).filter(Boolean) as BlogPost[];

blogPosts.sort(
(a, b) => b.metadata.date.getTime() - a.metadata.date.getTime(),
);
const sortedPosts = sortBlogPosts({
blogPosts,
sortPosts: options.sortPosts,
});

if (options.sortPosts === 'ascending') {
return blogPosts.reverse();
}
return blogPosts;
return sortedPosts;
slorber marked this conversation as resolved.
Show resolved Hide resolved
}

export type LinkifyParams = {
Expand Down
4 changes: 2 additions & 2 deletions packages/docusaurus-plugin-content-blog/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
}).default(DEFAULT_OPTIONS.feedOptions),
authorsMapPath: Joi.string().default(DEFAULT_OPTIONS.authorsMapPath),
readingTime: Joi.function().default(() => DEFAULT_OPTIONS.readingTime),
sortPosts: Joi.string()
.valid('descending', 'ascending')
sortPosts: Joi.alternatives()
.try(Joi.string().valid('descending', 'ascending'), Joi.function())
.default(DEFAULT_OPTIONS.sortPosts),
}).default(DEFAULT_OPTIONS);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,16 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
defaultReadingTime: ReadingTimeFunction;
},
) => number | undefined;

export type SortBlogPostsFn = (params: {
blogPosts: BlogPost[];
}) => void | BlogPost[];

export type SortBlogPostsPreset = 'ascending' | 'descending';

// eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style
export type SortPresets = Record<SortBlogPostsPreset, SortBlogPostsFn>;

/**
* Plugin options after normalization.
*/
Expand Down Expand Up @@ -418,7 +428,7 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
/** A callback to customize the reading time number displayed. */
readingTime: ReadingTimeFunctionOption;
/** Governs the direction of blog post sorting. */
sortPosts: 'ascending' | 'descending';
sortPosts: SortBlogPostsPreset | SortBlogPostsFn;
};

/**
Expand Down