/** * 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 path from 'path'; import fs from 'fs-extra'; import {Feed, type Author as FeedAuthor} from 'feed'; import * as srcset from 'srcset'; import { getDataFilePath, normalizeUrl, readOutputHTMLFile, } from '@docusaurus/utils'; import { blogPostContainerID, applyTrailingSlash, } from '@docusaurus/utils-common'; import {load as cheerioLoad} from 'cheerio'; import logger from '@docusaurus/logger'; import type {BlogContentPaths} from './types'; import type {DocusaurusConfig, HtmlTags, LoadContext} from '@docusaurus/types'; import type { FeedType, PluginOptions, Author, BlogPost, BlogFeedItem, FeedOptions, FeedXSLTOptions, } from '@docusaurus/plugin-content-blog'; async function generateBlogFeed({ blogPosts, options, siteConfig, outDir, locale, }: { blogPosts: BlogPost[]; options: PluginOptions; siteConfig: DocusaurusConfig; outDir: string; locale: string; }): Promise { if (!blogPosts.length) { return null; } const {feedOptions, routeBasePath} = options; const {url: siteUrl, baseUrl, title, favicon, trailingSlash} = siteConfig; const blogBaseUrl = applyTrailingSlash( normalizeUrl([siteUrl, baseUrl, routeBasePath]), { trailingSlash, baseUrl, }, ); const blogPostsForFeed = feedOptions.limit === false || feedOptions.limit === null ? blogPosts : blogPosts.slice(0, feedOptions.limit); const updated = blogPostsForFeed[0]?.metadata.date; const feed = new Feed({ id: blogBaseUrl, title: feedOptions.title ?? `${title} Blog`, updated, language: feedOptions.language ?? locale, link: blogBaseUrl, description: feedOptions.description ?? `${siteConfig.title} Blog`, favicon: favicon ? normalizeUrl([siteUrl, baseUrl, favicon]) : undefined, copyright: feedOptions.copyright, }); const createFeedItems = options.feedOptions.createFeedItems ?? defaultCreateFeedItems; const feedItems = await createFeedItems({ blogPosts: blogPostsForFeed, siteConfig, outDir, defaultCreateFeedItems, }); feedItems.forEach(feed.addItem); return feed; } async function defaultCreateFeedItems({ blogPosts, siteConfig, outDir, }: { blogPosts: BlogPost[]; siteConfig: DocusaurusConfig; outDir: string; }): Promise { const {url: siteUrl, baseUrl, trailingSlash} = siteConfig; function toFeedAuthor(author: Author): FeedAuthor { return {name: author.name, link: author.url, email: author.email}; } return Promise.all( blogPosts.map(async (post) => { const { metadata: { title: metadataTitle, permalink, date, description, authors, tags, }, } = post; const content = await readOutputHTMLFile( permalink.replace(baseUrl, ''), outDir, trailingSlash, ); const $ = cheerioLoad(content); const blogPostAbsoluteUrl = applyTrailingSlash( normalizeUrl([siteUrl, permalink]), { trailingSlash, baseUrl, }, ); const toAbsoluteUrl = (src: string) => String(new URL(src, blogPostAbsoluteUrl)); // Make links and image urls absolute // See https://github.com/facebook/docusaurus/issues/9136 $(`div#${blogPostContainerID} a, div#${blogPostContainerID} img`).each( (_, elm) => { if (elm.tagName === 'a') { const {href} = elm.attribs; if (href) { elm.attribs.href = toAbsoluteUrl(href); } } else if (elm.tagName === 'img') { const {src, srcset: srcsetAttr} = elm.attribs; if (src) { elm.attribs.src = toAbsoluteUrl(src); } if (srcsetAttr) { elm.attribs.srcset = srcset.stringify( srcset.parse(srcsetAttr).map((props) => ({ ...props, url: toAbsoluteUrl(props.url), })), ); } } }, ); const feedItem: BlogFeedItem = { title: metadataTitle, id: blogPostAbsoluteUrl, link: blogPostAbsoluteUrl, date, description, // Atom feed demands the "term", while other feeds use "name" category: tags.map((tag) => ({name: tag.label, term: tag.label})), content: $(`#${blogPostContainerID}`).html()!, }; // json1() method takes the first item of authors array // it causes an error when authors array is empty const feedItemAuthors = authors.map(toFeedAuthor); if (feedItemAuthors.length > 0) { feedItem.author = feedItemAuthors; } return feedItem; }), ); } async function resolveXsltFilePaths({ xsltFilePath, contentPaths, }: { xsltFilePath: string; contentPaths: BlogContentPaths; }) { const xsltAbsolutePath: string = path.isAbsolute(xsltFilePath) ? xsltFilePath : (await getDataFilePath({filePath: xsltFilePath, contentPaths})) ?? path.resolve(contentPaths.contentPath, xsltFilePath); if (!(await fs.pathExists(xsltAbsolutePath))) { throw new Error( logger.interpolate`Blog feed XSLT file not found at path=${path.relative( process.cwd(), xsltAbsolutePath, )}`, ); } const parsedPath = path.parse(xsltAbsolutePath); const cssAbsolutePath = path.resolve( parsedPath.dir, `${parsedPath.name}.css`, ); if (!(await fs.pathExists(xsltAbsolutePath))) { throw new Error( logger.interpolate`Blog feed XSLT file was found at path=${path.relative( process.cwd(), xsltAbsolutePath, )} But its expected co-located CSS file could not be found at path=${path.relative( process.cwd(), cssAbsolutePath, )} If you want to provide a custom XSLT file, you must provide a CSS file with the exact same name.`, ); } return {xsltAbsolutePath, cssAbsolutePath}; } async function generateXsltFiles({ xsltFilePath, generatePath, contentPaths, }: { xsltFilePath: string; generatePath: string; contentPaths: BlogContentPaths; }) { const {xsltAbsolutePath, cssAbsolutePath} = await resolveXsltFilePaths({ xsltFilePath, contentPaths, }); const xsltOutputPath = path.join( generatePath, path.basename(xsltAbsolutePath), ); const cssOutputPath = path.join(generatePath, path.basename(cssAbsolutePath)); await fs.copy(xsltAbsolutePath, xsltOutputPath); await fs.copy(cssAbsolutePath, cssOutputPath); } // This modifies the XML feed content to add a relative href to the XSLT file // Good enough for now: we probably don't need a full XML parser just for that // See also https://darekkay.com/blog/rss-styling/ function injectXslt({ feedContent, xsltFilePath, }: { feedContent: string; xsltFilePath: string; }) { return feedContent.replace( '', ``, ); } const FeedConfigs: Record< FeedType, { outputFileName: string; getContent: (feed: Feed) => string; getXsltFilePath: (xslt: FeedXSLTOptions) => string | null; } > = { rss: { outputFileName: 'rss.xml', getContent: (feed) => feed.rss2(), getXsltFilePath: (xslt) => xslt.rss, }, atom: { outputFileName: 'atom.xml', getContent: (feed) => feed.atom1(), getXsltFilePath: (xslt) => xslt.atom, }, json: { outputFileName: 'feed.json', getContent: (feed) => feed.json1(), getXsltFilePath: () => null, }, }; async function createBlogFeedFile({ feed, feedType, generatePath, feedOptions, contentPaths, }: { feed: Feed; feedType: FeedType; generatePath: string; feedOptions: FeedOptions; contentPaths: BlogContentPaths; }) { try { const feedConfig = FeedConfigs[feedType]; let feedContent = feedConfig.getContent(feed); const xsltFilePath = feedConfig.getXsltFilePath(feedOptions.xslt); if (xsltFilePath) { await generateXsltFiles({xsltFilePath, contentPaths, generatePath}); feedContent = injectXslt({feedContent, xsltFilePath}); } const outputPath = path.join(generatePath, feedConfig.outputFileName); await fs.outputFile(outputPath, feedContent); } catch (err) { throw new Error(`Generating ${feedType} feed failed.`, { cause: err as Error, }); } } function shouldBeInFeed(blogPost: BlogPost): boolean { const excluded = blogPost.metadata.frontMatter.draft || blogPost.metadata.frontMatter.unlisted; return !excluded; } export async function createBlogFeedFiles({ blogPosts: allBlogPosts, options, siteConfig, outDir, locale, contentPaths, }: { blogPosts: BlogPost[]; options: PluginOptions; siteConfig: DocusaurusConfig; outDir: string; locale: string; contentPaths: BlogContentPaths; }): Promise { const blogPosts = allBlogPosts.filter(shouldBeInFeed); const feed = await generateBlogFeed({ blogPosts, options, siteConfig, outDir, locale, }); const feedTypes = options.feedOptions.type; if (!feed || !feedTypes) { return; } await Promise.all( feedTypes.map((feedType) => createBlogFeedFile({ feed, feedType, generatePath: path.join(outDir, options.routeBasePath), feedOptions: options.feedOptions, contentPaths, }), ), ); } export function createFeedHtmlHeadTags({ context, options, }: { context: LoadContext; options: PluginOptions; }): HtmlTags { const feedTypes = options.feedOptions.type; if (!feedTypes) { return []; } const feedTitle = options.feedOptions.title ?? context.siteConfig.title; const feedsConfig = { rss: { type: 'application/rss+xml', path: 'rss.xml', title: `${feedTitle} RSS Feed`, }, atom: { type: 'application/atom+xml', path: 'atom.xml', title: `${feedTitle} Atom Feed`, }, json: { type: 'application/json', path: 'feed.json', title: `${feedTitle} JSON Feed`, }, }; const headTags: HtmlTags = []; feedTypes.forEach((feedType) => { const { type, path: feedConfigPath, title: feedConfigTitle, } = feedsConfig[feedType]; headTags.push({ tagName: 'link', attributes: { rel: 'alternate', type, href: normalizeUrl([ context.siteConfig.baseUrl, options.routeBasePath, feedConfigPath, ]), title: feedConfigTitle, }, }); }); return headTags; }