docusaurus/packages/docusaurus-plugin-content-blog/src/feed.ts
Rohan Thakur 7be1feaa0a
feat(blog): add feed xlst options to render beautiful RSS and Atom feeds (#9252)
Co-authored-by: ozakione <29860391+OzakIOne@users.noreply.github.com>
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
2024-08-02 18:50:48 +02:00

435 lines
11 KiB
TypeScript

/**
* 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<Feed | null> {
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<BlogFeedItem[]> {
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(
'<?xml version="1.0" encoding="utf-8"?>',
`<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="${path.basename(
xsltFilePath,
)}"?>`,
);
}
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<void> {
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;
}