From eb1a24e6c840f07272d86d9b6600309940366fc1 Mon Sep 17 00:00:00 2001 From: Liviu Ionescu Date: Mon, 22 Jan 2024 13:17:23 +0200 Subject: [PATCH] add sortPostsByEventDate --- .../src/blogDateComparators.ts | 36 +++ .../src/blogUtils.ts | 29 +- .../src/frontMatterEventDates.ts | 282 ++++++++++++++++++ .../src/options.ts | 4 + .../src/plugin-content-blog.d.ts | 38 +++ 5 files changed, 384 insertions(+), 5 deletions(-) create mode 100644 packages/docusaurus-plugin-content-blog/src/frontMatterEventDates.ts diff --git a/packages/docusaurus-plugin-content-blog/src/blogDateComparators.ts b/packages/docusaurus-plugin-content-blog/src/blogDateComparators.ts index 0b96c3483e67..e484fc208826 100644 --- a/packages/docusaurus-plugin-content-blog/src/blogDateComparators.ts +++ b/packages/docusaurus-plugin-content-blog/src/blogDateComparators.ts @@ -30,3 +30,39 @@ export const blogDateNewestComparator = (a: BlogPost, b: BlogPost): number => { return compareDates(a.metadata.date, b.metadata.date); }; + +export const blogDateComparator = (a: BlogPost, b: BlogPost): number => { + // If event dates are available, prefer them over post creation dates. + if (a.metadata.eventDate || b.metadata.eventDate) { + let aDate: Date = a.metadata.eventDate + ? a.metadata.eventDate + : a.metadata.date; + let bDate: Date = b.metadata.eventDate + ? b.metadata.eventDate + : b.metadata.date; + + let value: number = compareDates(aDate, bDate); + if (value !== 0) { + return value; + } + + // For identical event dates, use event end dates if available, + // use them as secondary criteria. + if (a.metadata.eventEndDate || b.metadata.eventEndDate) { + if (a.metadata.eventEndDate) { + aDate = a.metadata.eventEndDate; + } + if (b.metadata.eventEndDate) { + bDate = b.metadata.eventEndDate; + } + + value = compareDates(aDate, bDate); + if (value !== 0) { + return value; + } + } + // If all are the same, fall through and compare posts creation dates. + } + + return compareDates(a.metadata.date, b.metadata.date); +}; diff --git a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts index 1c6efe3d1e80..2062e54bbf52 100644 --- a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts +++ b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts @@ -30,6 +30,11 @@ import { } from '@docusaurus/utils'; import {validateBlogPostFrontMatter} from './frontMatter'; import {type AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors'; +import {blogDateComparator} from './blogDateComparators'; +import { + type ParsedEventDates, + parseFrontMatterEventDates, +} from './frontMatterEventDates'; import type {LoadContext, ParseFrontMatter} from '@docusaurus/types'; import type { PluginOptions, @@ -367,6 +372,10 @@ async function processBlogSourceFile( ]); const authors = getBlogPostAuthors({authorsMap, frontMatter, baseUrl}); + const parsedEventDates: ParsedEventDates = options.sortPostsByEventDate + ? parseFrontMatterEventDates({frontMatter, context, options}) + : ({} as ParsedEventDates); + const formattedDateForArchive = formatBlogPostDate({ locale: i18n.currentLocale, date: postDate, @@ -374,7 +383,11 @@ async function processBlogSourceFile( hideYear: options.hidePostYearInArchive, }); - const yearForArchive: string = postDate.getUTCFullYear().toString(); + const yearForArchive: string = ( + parsedEventDates.eventDate ? parsedEventDates.eventDate : postDate + ) + .getUTCFullYear() + .toString(); return { id: slug, @@ -387,7 +400,9 @@ async function processBlogSourceFile( date: postDate, formattedDate, yearForArchive, - formattedDateForArchive, + formattedDateForArchive: parsedEventDates.eventDate + ? (parsedEventDates.eventDateFormattedForArchive as string) + : formattedDateForArchive, tags: normalizeFrontMatterTags(tagsBasePath, frontMatter.tags), readingTime: showReadingTime ? options.readingTime({ @@ -409,6 +424,12 @@ async function processBlogSourceFile( calendar: i18n.localeConfigs[i18n.currentLocale]!.calendar, }) : undefined, + eventDate: parsedEventDates.eventDate, + eventEndDate: parsedEventDates.eventEndDate, + eventDateFormatted: parsedEventDates.eventDateFormatted, + eventDateFormattedForArchive: + parsedEventDates.eventDateFormattedForArchive, + eventRangeFormatted: parsedEventDates.eventRangeFormatted, }, content, }; @@ -497,9 +518,7 @@ 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(), - ); + blogPosts.sort(blogDateComparator); if (options.sortPosts === 'ascending') { return blogPosts.reverse(); diff --git a/packages/docusaurus-plugin-content-blog/src/frontMatterEventDates.ts b/packages/docusaurus-plugin-content-blog/src/frontMatterEventDates.ts new file mode 100644 index 000000000000..63ca7cd81922 --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/frontMatterEventDates.ts @@ -0,0 +1,282 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// import logger from '@docusaurus/logger'; +import type { + BlogPostFrontMatter, + PluginOptions, +} from '@docusaurus/plugin-content-blog'; +import type {I18n, LoadContext} from '@docusaurus/types'; + +// If month/day are not present, extend with defaults. +// Note: It does not accept negative years. +const parseEventDate = ({ + frontMatterEventDate, +}: { + frontMatterEventDate: string; +}): Date => { + // For weird reasons, 2 digit years are considered relative to epoch. + // To allow dates in the antiquity, set explicitly. + const year: number = parseInt(frontMatterEventDate.replace(/-.*/, ''), 10); + + // Expect YYYY-MM-DD, YYYY-MM, YYYY + const dateParts: number[] = frontMatterEventDate + .split('-') + .map((str) => Number(str)); + let date; + if ( + dateParts.length === 3 && + Number.isInteger(dateParts[0]) && + Number.isInteger(dateParts[1]) && + Number.isInteger(dateParts[2]) + ) { + date = new Date( + dateParts[0] as number, + dateParts[1] as number, + dateParts[2], + ); + } else if ( + dateParts.length === 2 && + Number.isInteger(dateParts[0]) && + Number.isInteger(dateParts[1]) + ) { + date = new Date(dateParts[0] as number, dateParts[1] as number, 15); + date.setFullYear(year); + } else if (dateParts.length === 1 && Number.isInteger(dateParts[0])) { + date = new Date(dateParts[0] as number, 7, 1); + } else { + // Last resort, try to parse as standard date. + date = new Date(frontMatterEventDate); + } + + date.setFullYear(year); + return date; +}; + +const formatEventDate = ({ + frontMatterEventDate, + eventDate, + i18n, + hideYear, +}: { + frontMatterEventDate: string; + eventDate: Date; + i18n: I18n; + hideYear?: boolean; +}): string => { + const locale = i18n.currentLocale; + const {calendar} = i18n.localeConfigs[i18n.currentLocale]!; + + // Expect YYYY-MM-DD, YYYY-MM, YYYY + const dateParts: number[] = frontMatterEventDate + .split('-') + .map((str) => Number(str)); + + let formattedDate; + if ( + dateParts.length === 3 && + Number.isInteger(dateParts[0]) && + Number.isInteger(dateParts[1]) && + Number.isInteger(dateParts[2]) + ) { + // YYYY-MM-DD + formattedDate = new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'long', + year: hideYear ? undefined : 'numeric', + timeZone: 'UTC', + calendar, + }).format(eventDate); + } else if ( + dateParts.length === 2 && + Number.isInteger(dateParts[0]) && + Number.isInteger(dateParts[1]) + ) { + // YYYY-MM + formattedDate = new Intl.DateTimeFormat(locale, { + // day: 'numeric', + month: 'long', + year: hideYear ? undefined : 'numeric', + timeZone: 'UTC', + calendar, + }).format(eventDate); + } else if (dateParts.length === 1 && Number.isInteger(dateParts[0])) { + // YYYY + formattedDate = hideYear + ? '' + : new Intl.DateTimeFormat(locale, { + // day: 'numeric', + // month: 'long', + year: 'numeric', + timeZone: 'UTC', + calendar, + }).format(eventDate); + } else { + // Last resort, try to parse as standard date. + formattedDate = new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'long', + year: hideYear ? undefined : 'numeric', + timeZone: 'UTC', + calendar, + }).format(eventDate); + } + + return formattedDate; +}; + +// The code is a bit tricky; it cannot simply use formatRange() +// because the date may not be present and it must not be shown +// as 15. +const formatEventRange = ({ + frontMatterEventDate, + eventDate, + frontMatterEventEndDate, + eventEndDate, + i18n, +}: { + frontMatterEventDate: string; + eventDate: Date; + frontMatterEventEndDate: string; + eventEndDate: Date; + i18n: I18n; +}): string => { + const locale = i18n.currentLocale; + const {calendar} = i18n.localeConfigs[i18n.currentLocale]!; + + const dateParts = frontMatterEventDate + .split('-') + .map((str) => parseInt(str, 10)); + + const endDateParts: number[] = frontMatterEventEndDate + .split('-') + .map((str) => parseInt(str, 10)); + + let range = ''; + if (dateParts[0] === endDateParts[0]) { + // Same year. + if ( + dateParts.length === 3 && + endDateParts.length === 3 && + dateParts[1] === endDateParts[1] + ) { + // YYYY-MM-DDbegin YYYY-MM-DDend + // Both have days, same month, format as '1 - 4 November 1993'. + range = new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'UTC', + calendar, + }).formatRange(eventDate, eventEndDate); + } else if ( + dateParts.length === 2 && + endDateParts.length === 2 && + dateParts[1] !== endDateParts[1] + ) { + // YYYY-MMbegin YYYY-MMend + // No days, different months, format as 'October - November 1993'. + range = new Intl.DateTimeFormat(locale, { + // day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'UTC', + calendar, + }).formatRange(eventDate, eventEndDate); + } else { + // No optimizations possible, cannot use formatRange() since the + // days may not be present and the extrapolated 15 must not be shown. + const from = new Intl.DateTimeFormat(locale, { + day: dateParts.length > 2 ? 'numeric' : undefined, + month: 'long', + // year: 'numeric', + timeZone: 'UTC', + calendar, + }).format(eventDate); + const to = new Intl.DateTimeFormat(locale, { + day: endDateParts.length > 2 ? 'numeric' : undefined, + month: 'long', + year: 'numeric', + timeZone: 'UTC', + calendar, + }).format(eventDate); + + range = `${from} - ${to}`; + } + } else { + // Different years. Manually compose the range. + const from = formatEventDate({frontMatterEventDate, eventDate, i18n}); + const to = formatEventDate({ + frontMatterEventDate: frontMatterEventEndDate, + eventDate: eventEndDate, + i18n, + }); + + range = `${from} - ${to}`; + } + return range; +}; + +// ---------------------------------------------------------------------------- + +export type ParsedEventDates = { + eventDate?: Date; + eventEndDate?: Date; + eventDateFormatted?: string; + eventDateFormattedForArchive?: string; + eventRangeFormatted?: string; + eventRangeFormattedForArchive?: string; +}; + +export const parseFrontMatterEventDates = ({ + frontMatter, + context, + options, +}: { + frontMatter: BlogPostFrontMatter; + context: LoadContext; + options: PluginOptions; +}): ParsedEventDates => { + const {i18n} = context; + + const result: ParsedEventDates = {}; + + if (frontMatter.event_date) { + result.eventDate = parseEventDate({ + frontMatterEventDate: frontMatter.event_date, + }); + result.eventDateFormatted = formatEventDate({ + frontMatterEventDate: frontMatter.event_date, + eventDate: result.eventDate, + i18n, + }); + result.eventDateFormattedForArchive = formatEventDate({ + frontMatterEventDate: frontMatter.event_date, + eventDate: result.eventDate, + i18n, + hideYear: options.hidePostYearInArchive, + }); + + if (frontMatter.event_end_date) { + result.eventEndDate = parseEventDate({ + frontMatterEventDate: frontMatter.event_end_date, + }); + result.eventRangeFormatted = formatEventRange({ + frontMatterEventDate: frontMatter.event_date, + eventDate: result.eventDate, + frontMatterEventEndDate: frontMatter.event_end_date, + eventEndDate: result.eventEndDate, + i18n, + }); + } else { + // Actually not a range, only the begin date. + result.eventRangeFormatted = result.eventDateFormatted; + } + } + // logger.info(result); + return result; +}; diff --git a/packages/docusaurus-plugin-content-blog/src/options.ts b/packages/docusaurus-plugin-content-blog/src/options.ts index d24114c1449a..18b0e8aa725b 100644 --- a/packages/docusaurus-plugin-content-blog/src/options.ts +++ b/packages/docusaurus-plugin-content-blog/src/options.ts @@ -53,6 +53,7 @@ export const DEFAULT_OPTIONS: PluginOptions = { sortPosts: 'descending', showLastUpdateTime: false, showLastUpdateAuthor: false, + sortPostsByEventDate: false, hidePostYearInArchive: false, }; @@ -141,6 +142,9 @@ const PluginOptionSchema = Joi.object({ showLastUpdateAuthor: Joi.bool().default( DEFAULT_OPTIONS.showLastUpdateAuthor, ), + sortPostsByEventDate: Joi.bool().default( + DEFAULT_OPTIONS.sortPostsByEventDate, + ), hidePostYearInArchive: Joi.bool().default( DEFAULT_OPTIONS.hidePostYearInArchive, ), diff --git a/packages/docusaurus-plugin-content-blog/src/plugin-content-blog.d.ts b/packages/docusaurus-plugin-content-blog/src/plugin-content-blog.d.ts index 0ae99b856ecd..577b58d5297c 100644 --- a/packages/docusaurus-plugin-content-blog/src/plugin-content-blog.d.ts +++ b/packages/docusaurus-plugin-content-blog/src/plugin-content-blog.d.ts @@ -165,6 +165,16 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the /** Allows overriding the last updated author and/or date. */ last_update?: FileChange; + + /** + * Event date, a string formatted as YYYY, YYYY-MM or YYYY-MM-DD. + */ + event_date?: string; + /** + * Event end date, a string formatted as YYYY, YYYY-MM or YYYY-MM-DD. + * (for example for conferences) + */ + event_end_date?: string; }; export type BlogPostFrontMatterAuthor = Author & { @@ -263,6 +273,27 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the * Marks the post as unlisted and visibly hides it unless directly accessed. */ readonly unlisted: boolean; + /** + * The `event_date` in binary. + */ + readonly eventDate?: Date; + /** + * The `event_end_date` in binary. + */ + readonly eventEndDate?: Date; + /** + * The `event_date` formatted according to locales. + */ + readonly eventDateFormatted?: string; + /** + * The `event_date` formatted according to locales, + * possibly without year. + */ + readonly eventDateFormattedForArchive?: string; + /** + * The event interval formatted according to locales. + */ + readonly eventRangeFormatted?: string; /** Thee event/post year, for archive grouping. */ readonly yearForArchive: string; @@ -458,6 +489,8 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the showLastUpdateTime?: boolean; /** Whether to display the author who last updated the doc. */ showLastUpdateAuthor?: boolean; + /** Whether to sort posts by `event_date`. */ + sortPostsByEventDate?: boolean; /** Whether to do not show redundant year in Archive grouping. */ hidePostYearInArchive?: boolean; }; @@ -554,6 +587,11 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the { /** The publish date of the post. Serialized from the `Date` object. */ date: string; + + /** The event date of the post. Serialized from the `Date` object. */ + eventDate: string; + /** The event end date of the post. Serialized from the `Date` object. */ + eventEndDate: string; } >;