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(v2): Implement plugin creating feed for blog posts #1916

Merged
merged 4 commits into from
Nov 6, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG-2.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Add feed for blog posts.
- **HOTFIX for 2.0.0-alpha.32** - Fix build compilation if exists only one code tab.
- Add table of contents highlighting on scroll.
- **BREAKING** `prismTheme` is renamed to `theme` as part new `prism` object in `themeConfig` field in your `docusaurus.config.js`. Eg:
Expand All @@ -20,7 +21,6 @@
### Features

- Add `<Redirect>` component for easy client side redirect. Example Usage:

```js
import React from 'react';
import {Redirect} from '@docusaurus/router';
Expand Down
1 change: 1 addition & 0 deletions packages/docusaurus-plugin-content-blog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"@docusaurus/mdx-loader": "^2.0.0-alpha.32",
"@docusaurus/utils": "^2.0.0-alpha.32",
"feed": "^4.0.0",
"fs-extra": "^8.1.0",
"globby": "^10.0.1",
"loader-utils": "^1.2.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`blogFeed atom can show feed without posts 1`] = `null`;

exports[`blogFeed atom shows feed item for each post 1`] = `
"<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
<feed xmlns=\\"http://www.w3.org/2005/Atom\\">
<id>https://docusaurus.io/blog</id>
<title>Hello Blog</title>
<updated>2019-01-01T00:00:00.000Z</updated>
<generator>https://github.com/jpmonette/feed</generator>
<link rel=\\"alternate\\" href=\\"https://docusaurus.io/blog\\"/>
<subtitle>Hello Blog</subtitle>
<icon>https://docusaurus.io/image/favicon.ico</icon>
<rights>Copyright</rights>
<entry>
<title type=\\"html\\"><![CDATA[date-matter]]></title>
<id>date-matter</id>
<link href=\\"https://docusaurus.io/blog/2019/01/01/date-matter\\"/>
<updated>2019-01-01T00:00:00.000Z</updated>
<summary type=\\"html\\"><![CDATA[date inside front matter]]></summary>
</entry>
<entry>
<title type=\\"html\\"><![CDATA[Happy 1st Birthday Slash!]]></title>
<id>Happy 1st Birthday Slash!</id>
<link href=\\"https://docusaurus.io/blog/2018/12/14/Happy-First-Birthday-Slash\\"/>
<updated>2018-12-14T00:00:00.000Z</updated>
<summary type=\\"html\\"><![CDATA[pattern name]]></summary>
</entry>
</feed>"
`;

exports[`blogFeed rss can show feed without posts 1`] = `null`;

exports[`blogFeed rss shows feed item for each post 1`] = `
"<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
<rss version=\\"2.0\\">
<channel>
<title>Hello Blog</title>
<link>https://docusaurus.io/blog</link>
<description>Hello Blog</description>
<lastBuildDate>Tue, 01 Jan 2019 00:00:00 GMT</lastBuildDate>
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<generator>https://github.com/jpmonette/feed</generator>
<copyright>Copyright</copyright>
<item>
<title><![CDATA[date-matter]]></title>
<link>https://docusaurus.io/blog/2019/01/01/date-matter</link>
<guid>https://docusaurus.io/blog/2019/01/01/date-matter</guid>
<pubDate>Tue, 01 Jan 2019 00:00:00 GMT</pubDate>
<description><![CDATA[date inside front matter]]></description>
</item>
<item>
<title><![CDATA[Happy 1st Birthday Slash!]]></title>
<link>https://docusaurus.io/blog/2018/12/14/Happy-First-Birthday-Slash</link>
<guid>https://docusaurus.io/blog/2018/12/14/Happy-First-Birthday-Slash</guid>
<pubDate>Fri, 14 Dec 2018 00:00:00 GMT</pubDate>
<description><![CDATA[pattern name]]></description>
</item>
</channel>
</rss>"
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import path from 'path';
import {generateBlogFeed} from '../blogUtils';
import {LoadContext} from '@docusaurus/types';
import {PluginOptions} from '../types';

describe('blogFeed', () => {
['atom', 'rss'].forEach(feedType => {
describe(`${feedType}`, () => {
test('can show feed without posts', async () => {
const siteConfig = {
title: 'Hello',
baseUrl: '/',
url: 'https://docusaurus.io',
favicon: 'image/favicon.ico',
};

const feed = await generateBlogFeed(
{
siteDir: __dirname,
siteConfig,
} as LoadContext,
{
path: 'invalid-blog-path',
routeBasePath: 'blog',
include: ['*.md', '*.mdx'],
feedOptions: {
type: feedType as any,
copyright: 'Copyright',
},
} as PluginOptions,
);
const feedContent =
feed && (feedType === 'rss' ? feed.rss2() : feed.atom1());
expect(feedContent).toMatchSnapshot();
});

test('shows feed item for each post', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const generatedFilesDir = path.resolve(siteDir, '.docusaurus');
const siteConfig = {
title: 'Hello',
baseUrl: '/',
url: 'https://docusaurus.io',
favicon: 'image/favicon.ico',
};

const feed = await generateBlogFeed(
{
siteDir,
siteConfig,
generatedFilesDir,
} as LoadContext,
{
path: 'blog',
routeBasePath: 'blog',
include: ['*r*.md', '*.mdx'], // skip no-date.md - it won't play nice with snapshots
feedOptions: {
type: feedType as any,
copyright: 'Copyright',
},
} as PluginOptions,
);
const feedContent =
feed && (feedType === 'rss' ? feed.rss2() : feed.atom1());
expect(feedContent).toMatchSnapshot();
});
});
});
});
147 changes: 147 additions & 0 deletions packages/docusaurus-plugin-content-blog/src/blogUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import fs from 'fs-extra';
import globby from 'globby';
import path from 'path';
import {Feed} from 'feed';
import {PluginOptions, BlogPost, DateLink} from './types';
import {parse, normalizeUrl} from '@docusaurus/utils';
import {LoadContext} from '@docusaurus/types';

export function truncate(fileString: string, truncateMarker: RegExp | string) {
const truncated =
typeof truncateMarker === 'string'
? fileString.includes(truncateMarker)
: truncateMarker.test(fileString);
return truncated ? fileString.split(truncateMarker)[0] : fileString;
}

// YYYY-MM-DD-{name}.mdx?
// prefer named capture, but old node version do not support
const FILENAME_PATTERN = /^(\d{4}-\d{1,2}-\d{1,2})-?(.*?).mdx?$/;

function toUrl({date, link}: DateLink) {
return `${date
.toISOString()
.substring(0, '2019-01-01'.length)
.replace(/-/g, '/')}/${link}`;
}

export async function generateBlogFeed(
context: LoadContext,
options: PluginOptions,
) {
if (!options.feedOptions) {
throw new Error(
'Invalid options - `feedOptions` is not expected to be null.',
);
}
const {siteDir, siteConfig} = context;
const contentPath = path.resolve(siteDir, options.path);
const blogPosts = await generateBlogPosts(contentPath, context, options);
if (blogPosts == null) {
return null;
}

const {feedOptions, routeBasePath} = options;
const {url: siteUrl, title, favicon} = siteConfig;
const blogBaseUrl = normalizeUrl([siteUrl, routeBasePath]);

const updated =
(blogPosts[0] && blogPosts[0].metadata.date) ||
new Date('2015-10-25T16:29:00.000-07:00');

const feed = new Feed({
id: blogBaseUrl,
title: feedOptions.title || `${title} Blog`,
updated,
language: feedOptions.language,
link: blogBaseUrl,
description: feedOptions.description || `${siteConfig.title} Blog`,
favicon: normalizeUrl([siteUrl, favicon]),
copyright: feedOptions.copyright,
});

blogPosts.forEach(post => {
const {
id,
metadata: {title, permalink, date, description},
} = post;
feed.addItem({
title,
id: id,
link: normalizeUrl([siteUrl, permalink]),
date,
description,
});
});

return feed;
}

export async function generateBlogPosts(
blogDir: string,
{siteConfig, siteDir}: LoadContext,
options: PluginOptions,
) {
const {include, routeBasePath} = options;

if (!fs.existsSync(blogDir)) {
return null;
}

const {baseUrl = ''} = siteConfig;
const blogFiles = await globby(include, {
cwd: blogDir,
});

const blogPosts: BlogPost[] = [];

await Promise.all(
blogFiles.map(async (relativeSource: string) => {
// Cannot use path.join() as it resolves '../' and removes the '@site'. Let webpack loader resolve it.
const source = path.join(blogDir, relativeSource);
const aliasedSource = `@site/${path.relative(siteDir, source)}`;
const blogFileName = path.basename(relativeSource);

const fileString = await fs.readFile(source, 'utf-8');
const {frontMatter, excerpt} = parse(fileString);

let date;
// extract date and title from filename
const match = blogFileName.match(FILENAME_PATTERN);
let linkName = blogFileName.replace(/\.mdx?$/, '');
if (match) {
const [, dateString, name] = match;
date = new Date(dateString);
linkName = name;
}
// prefer usedefined date
if (frontMatter.date) {
date = new Date(frontMatter.date);
}
// use file create time for blog
date = date || (await fs.stat(source)).birthtime;
frontMatter.title = frontMatter.title || linkName;

blogPosts.push({
id: frontMatter.id || frontMatter.title,
metadata: {
permalink: normalizeUrl([
baseUrl,
routeBasePath,
frontMatter.id || toUrl({date, link: linkName}),
]),
source: aliasedSource,
description: frontMatter.description || excerpt,
date,
tags: frontMatter.tags,
title: frontMatter.title,
},
});
}),
);
blogPosts.sort(
(a, b) => b.metadata.date.getTime() - a.metadata.date.getTime(),
);

return blogPosts;
}
Loading