/** * 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 fs from 'fs-extra'; import path from 'path'; import _ from 'lodash'; import logger from '@docusaurus/logger'; import readingTime from 'reading-time'; import { parseMarkdownFile, normalizeUrl, aliasedSitePath, getEditUrl, getFolderContainingFile, posixPath, replaceMarkdownLinks, Globby, normalizeFrontMatterTags, groupTaggedItems, getTagVisibility, getFileCommitDate, getContentPathList, isUnlisted, isDraft, readLastUpdateData, } from '@docusaurus/utils'; import {validateBlogPostFrontMatter} from './frontMatter'; import {type AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors'; import type {LoadContext, ParseFrontMatter} from '@docusaurus/types'; import type { PluginOptions, ReadingTimeFunction, BlogPost, BlogTags, BlogPaginated, } from '@docusaurus/plugin-content-blog'; import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types'; export function truncate(fileString: string, truncateMarker: RegExp): string { return fileString.split(truncateMarker, 1).shift()!; } export function getSourceToPermalink(blogPosts: BlogPost[]): { [aliasedPath: string]: string; } { return Object.fromEntries( blogPosts.map(({metadata: {source, permalink}}) => [source, permalink]), ); } export function paginateBlogPosts({ blogPosts, basePageUrl, blogTitle, blogDescription, postsPerPageOption, pageBasePath, }: { blogPosts: BlogPost[]; basePageUrl: string; blogTitle: string; blogDescription: string; postsPerPageOption: number | 'ALL'; pageBasePath: string; }): BlogPaginated[] { const totalCount = blogPosts.length; const postsPerPage = postsPerPageOption === 'ALL' ? totalCount : postsPerPageOption; const numberOfPages = Math.ceil(totalCount / postsPerPage); const pages: BlogPaginated[] = []; function permalink(page: number) { return page > 0 ? normalizeUrl([basePageUrl, pageBasePath, `${page + 1}`]) : basePageUrl; } for (let page = 0; page < numberOfPages; page += 1) { pages.push({ items: blogPosts .slice(page * postsPerPage, (page + 1) * postsPerPage) .map((item) => item.id), metadata: { permalink: permalink(page), page: page + 1, postsPerPage, totalPages: numberOfPages, totalCount, previousPage: page !== 0 ? permalink(page - 1) : undefined, nextPage: page < numberOfPages - 1 ? permalink(page + 1) : undefined, blogDescription, blogTitle, }, }); } return pages; } export function shouldBeListed(blogPost: BlogPost): boolean { return !blogPost.metadata.unlisted; } export function getBlogTags({ blogPosts, ...params }: { blogPosts: BlogPost[]; blogTitle: string; blogDescription: string; postsPerPageOption: number | 'ALL'; pageBasePath: string; }): BlogTags { const groups = groupTaggedItems( blogPosts, (blogPost) => blogPost.metadata.tags, ); return _.mapValues(groups, ({tag, items: tagBlogPosts}) => { const tagVisibility = getTagVisibility({ items: tagBlogPosts, isUnlisted: (item) => item.metadata.unlisted, }); return { label: tag.label, items: tagVisibility.listedItems.map((item) => item.id), permalink: tag.permalink, pages: paginateBlogPosts({ blogPosts: tagVisibility.listedItems, basePageUrl: tag.permalink, ...params, }), unlisted: tagVisibility.unlisted, }; }); } const DATE_FILENAME_REGEX = /^(?.*)(?\d{4}[-/]\d{1,2}[-/]\d{1,2})[-/]?(?.*?)(?:\/index)?.mdx?$/; type ParsedBlogFileName = { date: Date | undefined; text: string; slug: string; }; export function parseBlogFileName( blogSourceRelative: string, ): ParsedBlogFileName { const dateFilenameMatch = blogSourceRelative.match(DATE_FILENAME_REGEX); if (dateFilenameMatch) { const {folder, text, date: dateString} = dateFilenameMatch.groups!; // Always treat dates as UTC by adding the `Z` const date = new Date(`${dateString!}Z`); const slugDate = dateString!.replace(/-/g, '/'); const slug = `/${slugDate}/${folder!}${text!}`; return {date, text: text!, slug}; } const text = blogSourceRelative.replace(/(?:\/index)?\.mdx?$/, ''); const slug = `/${text}`; return {date: undefined, text, slug}; } async function parseBlogPostMarkdownFile({ filePath, parseFrontMatter, }: { filePath: string; parseFrontMatter: ParseFrontMatter; }) { const fileContent = await fs.readFile(filePath, 'utf-8'); try { const result = await parseMarkdownFile({ filePath, fileContent, parseFrontMatter, removeContentTitle: true, }); return { ...result, frontMatter: validateBlogPostFrontMatter(result.frontMatter), }; } catch (err) { logger.error`Error while parsing blog post file path=${filePath}.`; throw err; } } const defaultReadingTime: ReadingTimeFunction = ({content, options}) => readingTime(content, options).minutes; async function processBlogSourceFile( blogSourceRelative: string, contentPaths: BlogContentPaths, context: LoadContext, options: PluginOptions, authorsMap?: AuthorsMap, ): Promise { const { siteConfig: { baseUrl, markdown: {parseFrontMatter}, }, siteDir, i18n, } = context; const { routeBasePath, tagsBasePath: tagsRouteBasePath, truncateMarker, showReadingTime, editUrl, } = options; // Lookup in localized folder in priority const blogDirPath = await getFolderContainingFile( getContentPathList(contentPaths), blogSourceRelative, ); const blogSourceAbsolute = path.join(blogDirPath, blogSourceRelative); const {frontMatter, content, contentTitle, excerpt} = await parseBlogPostMarkdownFile({ filePath: blogSourceAbsolute, parseFrontMatter, }); const aliasedSource = aliasedSitePath(blogSourceAbsolute, siteDir); const lastUpdate = await readLastUpdateData( blogSourceAbsolute, options, frontMatter.last_update, ); const draft = isDraft({frontMatter}); const unlisted = isUnlisted({frontMatter}); if (draft) { return undefined; } if (frontMatter.id) { logger.warn`name=${'id'} header option is deprecated in path=${blogSourceRelative} file. Please use name=${'slug'} option instead.`; } const parsedBlogFileName = parseBlogFileName(blogSourceRelative); async function getDate(): Promise { // Prefer user-defined date. if (frontMatter.date) { if (typeof frontMatter.date === 'string') { // Always treat dates as UTC by adding the `Z` return new Date(`${frontMatter.date}Z`); } // YAML only converts YYYY-MM-DD to dates and leaves others as strings. return frontMatter.date; } else if (parsedBlogFileName.date) { return parsedBlogFileName.date; } try { const result = await getFileCommitDate(blogSourceAbsolute, { age: 'oldest', includeAuthor: false, }); return result.date; } catch (err) { logger.warn(err); return (await fs.stat(blogSourceAbsolute)).birthtime; } } const date = await getDate(); const title = frontMatter.title ?? contentTitle ?? parsedBlogFileName.text; const description = frontMatter.description ?? excerpt ?? ''; const slug = frontMatter.slug ?? parsedBlogFileName.slug; const permalink = normalizeUrl([baseUrl, routeBasePath, slug]); function getBlogEditUrl() { const blogPathRelative = path.relative( blogDirPath, path.resolve(blogSourceAbsolute), ); if (typeof editUrl === 'function') { return editUrl({ blogDirPath: posixPath(path.relative(siteDir, blogDirPath)), blogPath: posixPath(blogPathRelative), permalink, locale: i18n.currentLocale, }); } else if (typeof editUrl === 'string') { const isLocalized = blogDirPath === contentPaths.contentPathLocalized; const fileContentPath = isLocalized && options.editLocalizedFiles ? contentPaths.contentPathLocalized : contentPaths.contentPath; const contentPathEditUrl = normalizeUrl([ editUrl, posixPath(path.relative(siteDir, fileContentPath)), ]); return getEditUrl(blogPathRelative, contentPathEditUrl); } return undefined; } const tagsBasePath = normalizeUrl([ baseUrl, routeBasePath, tagsRouteBasePath, ]); const authors = getBlogPostAuthors({authorsMap, frontMatter, baseUrl}); return { id: slug, metadata: { permalink, editUrl: getBlogEditUrl(), source: aliasedSource, title, description, date, tags: normalizeFrontMatterTags(tagsBasePath, frontMatter.tags), readingTime: showReadingTime ? options.readingTime({ content, frontMatter, defaultReadingTime, }) : undefined, hasTruncateMarker: truncateMarker.test(content), authors, frontMatter, unlisted, lastUpdatedAt: lastUpdate.lastUpdatedAt, lastUpdatedBy: lastUpdate.lastUpdatedBy, }, content, }; } export async function generateBlogPosts( contentPaths: BlogContentPaths, context: LoadContext, options: PluginOptions, ): Promise { const {include, exclude} = options; if (!(await fs.pathExists(contentPaths.contentPath))) { return []; } const blogSourceFiles = await Globby(include, { cwd: contentPaths.contentPath, ignore: exclude, }); const authorsMap = await getAuthorsMap({ contentPaths, authorsMapPath: options.authorsMapPath, }); async function doProcessBlogSourceFile(blogSourceFile: string) { try { return await processBlogSourceFile( blogSourceFile, contentPaths, context, options, authorsMap, ); } catch (err) { throw new Error( `Processing of blog source file path=${blogSourceFile} failed.`, {cause: err as Error}, ); } } const blogPosts = ( await Promise.all(blogSourceFiles.map(doProcessBlogSourceFile)) ).filter(Boolean) as BlogPost[]; blogPosts.sort( (a, b) => b.metadata.date.getTime() - a.metadata.date.getTime(), ); if (options.sortPosts === 'ascending') { return blogPosts.reverse(); } return blogPosts; } export type LinkifyParams = { filePath: string; fileString: string; } & Pick< BlogMarkdownLoaderOptions, 'sourceToPermalink' | 'siteDir' | 'contentPaths' | 'onBrokenMarkdownLink' >; export function linkify({ filePath, contentPaths, fileString, siteDir, sourceToPermalink, onBrokenMarkdownLink, }: LinkifyParams): string { const {newContent, brokenMarkdownLinks} = replaceMarkdownLinks({ siteDir, fileString, filePath, contentPaths, sourceToPermalink, }); brokenMarkdownLinks.forEach((l) => onBrokenMarkdownLink(l)); return newContent; } export async function applyProcessBlogPosts({ blogPosts, processBlogPosts, }: { blogPosts: BlogPost[]; processBlogPosts: PluginOptions['processBlogPosts']; }): Promise { const processedBlogPosts = await processBlogPosts({blogPosts}); if (Array.isArray(processedBlogPosts)) { return processedBlogPosts; } return blogPosts; }