feat(v2): Implement plugin creating feed for blog posts (#1916)

* feat(v2): Implement feed for blog posts

Fixes: #1698

Test plan:
- added tests

Ran `yarn build` on website with the following config (and disabled blog
from preset-classic):

```js
[
'@docusaurus/plugin-content-blog',
  {
    path: '../website-1.x/blog',
    feedOptions: {
      copyright: 'Copy',
      type: 'atom',
    },
  },
],
```
which genereted the following feed:
```xml
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://v2.docusaurus.io/blog</id>
    <title>Docusaurus Blog</title>
    <updated>2018-12-14T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://v2.docusaurus.io/blog"/>
    <subtitle>Docusaurus Blog</subtitle>
    <icon>https://v2.docusaurus.io/img/docusaurus.ico</icon>
    <rights>Copy</rights>
    <entry>
        <title type="html"><![CDATA[Happy 1st Birthday Slash!]]></title>
        <id>Happy 1st Birthday Slash!</id>
        <link href="https://v2.docusaurus.io/blog/2018/12/14/Happy-First-Birthday-Slash"/>
        <updated>2018-12-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[![First Birthday Slash](/img/docusaurus-slash-first-birthday.svg)]]></summary>
    </entry>
    <entry>
        <title type="html"><![CDATA[Towards Docusaurus 2]]></title>
        <id>Towards Docusaurus 2</id>
        <link href="https://v2.docusaurus.io/blog/2018/09/11/Towards-Docusaurus-2"/>
        <updated>2018-09-11T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Docusaurus was [officially announced](https://docusaurus.io/blog/2017/12/14/introducing-docusaurus) over nine months ago as a way to easily build open source documentation websites. Since then, it has amassed over 8,600 GitHub Stars, and is used by many popular open source projects such as [React Native](https://facebook.github.io/react-native/), [Babel](https://babeljs.io/), [Jest](https://jestjs.io/), [Reason](https://reasonml.github.io/) and [Prettier](https://prettier.io/).]]></summary>
    </entry>
    <entry>
        <title type="html"><![CDATA[How I Converted Profilo to Docusaurus in Under 2 Hours]]></title>
        <id>How I Converted Profilo to Docusaurus in Under 2 Hours</id>
        <link href="https://v2.docusaurus.io/blog/2018/04/30/How-I-Converted-Profilo-To-Docusaurus"/>
        <updated>2018-04-30T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[> _“Joel and I were discussing having a website and how it would have been great to launch with it. So I challenged myself to add Docusaurus support. It took just over an hour and a half. I'm going to send you a PR with the addition so you can take a look and see if you like it. Your workflow for adding docs wouldn't be much different from editing those markdown files.”_]]></summary>
    </entry>
    <entry>
        <title type="html"><![CDATA[Introducing Docusaurus]]></title>
        <id>Introducing Docusaurus</id>
        <link href="https://v2.docusaurus.io/blog/2017/12/14/introducing-docusaurus"/>
        <updated>2017-12-14T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[![Introducing Slash](/img/slash-introducing.svg)]]></summary>
    </entry>
</feed>
```

* new feedOptions type 'all' and use correct path
This commit is contained in:
Pawel Kadluczka 2019-11-05 23:45:31 -08:00 committed by Endi
parent c507028cb0
commit ff83e6f8bc
12 changed files with 405 additions and 84 deletions

View file

@ -2,6 +2,7 @@
## Unreleased ## Unreleased
- Add feed for blog posts.
- **HOTFIX for 2.0.0-alpha.32** - Fix build compilation if exists only one code tab. - **HOTFIX for 2.0.0-alpha.32** - Fix build compilation if exists only one code tab.
- Add table of contents highlighting on scroll. - 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: - **BREAKING** `prismTheme` is renamed to `theme` as part new `prism` object in `themeConfig` field in your `docusaurus.config.js`. Eg:
@ -20,7 +21,6 @@
### Features ### Features
- Add `<Redirect>` component for easy client side redirect. Example Usage: - Add `<Redirect>` component for easy client side redirect. Example Usage:
```js ```js
import React from 'react'; import React from 'react';
import {Redirect} from '@docusaurus/router'; import {Redirect} from '@docusaurus/router';

View file

@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"@docusaurus/mdx-loader": "^2.0.0-alpha.32", "@docusaurus/mdx-loader": "^2.0.0-alpha.32",
"@docusaurus/utils": "^2.0.0-alpha.32", "@docusaurus/utils": "^2.0.0-alpha.32",
"feed": "^4.0.0",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"globby": "^10.0.1", "globby": "^10.0.1",
"loader-utils": "^1.2.3", "loader-utils": "^1.2.3",

View file

@ -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>"
`;

View file

@ -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();
});
});
});
});

View file

@ -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;
}

View file

@ -5,16 +5,13 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import fs from 'fs-extra'; import fs from 'fs-extra';
import globby from 'globby';
import _ from 'lodash'; import _ from 'lodash';
import path from 'path'; import path from 'path';
import {parse, normalizeUrl, docuHash} from '@docusaurus/utils'; import {normalizeUrl, docuHash} from '@docusaurus/utils';
import { import {
DateLink,
PluginOptions, PluginOptions,
BlogTags, BlogTags,
BlogPost,
Tag, Tag,
BlogContent, BlogContent,
BlogItemsToModules, BlogItemsToModules,
@ -24,19 +21,10 @@ import {
LoadContext, LoadContext,
PluginContentLoadedActions, PluginContentLoadedActions,
ConfigureWebpackUtils, ConfigureWebpackUtils,
Props,
} from '@docusaurus/types'; } from '@docusaurus/types';
import {Configuration} from 'webpack'; import {Configuration} from 'webpack';
import {generateBlogFeed, generateBlogPosts} from './blogUtils';
// 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}`;
}
const DEFAULT_OPTIONS: PluginOptions = { const DEFAULT_OPTIONS: PluginOptions = {
path: 'blog', // Path to data on filesystem, relative to site dir. path: 'blog', // Path to data on filesystem, relative to site dir.
@ -74,69 +62,13 @@ export default function pluginContentBlog(
// Fetches blog contents and returns metadata for the necessary routes. // Fetches blog contents and returns metadata for the necessary routes.
async loadContent() { async loadContent() {
const {postsPerPage, include, routeBasePath} = options; const {postsPerPage, routeBasePath} = options;
const {siteConfig, siteDir} = context;
const blogDir = contentPath;
if (!fs.existsSync(blogDir)) { const blogPosts = await generateBlogPosts(contentPath, context, options);
if (!blogPosts) {
return null; 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(),
);
// Colocate next and prev metadata // Colocate next and prev metadata
blogPosts.forEach((blogPost, index) => { blogPosts.forEach((blogPost, index) => {
const prevItem = index > 0 ? blogPosts[index - 1] : null; const prevItem = index > 0 ? blogPosts[index - 1] : null;
@ -160,6 +92,9 @@ export default function pluginContentBlog(
// Example: `/blog`, `/blog/page/1`, `/blog/page/2` // Example: `/blog`, `/blog/page/1`, `/blog/page/2`
const totalCount = blogPosts.length; const totalCount = blogPosts.length;
const numberOfPages = Math.ceil(totalCount / postsPerPage); const numberOfPages = Math.ceil(totalCount / postsPerPage);
const {
siteConfig: {baseUrl = ''},
} = context;
const basePageUrl = normalizeUrl([baseUrl, routeBasePath]); const basePageUrl = normalizeUrl([baseUrl, routeBasePath]);
const blogListPaginated = []; const blogListPaginated = [];
@ -442,5 +377,41 @@ export default function pluginContentBlog(
}, },
}; };
}, },
async postBuild({outDir}: Props) {
if (!options.feedOptions) {
return;
}
const {
feedOptions: {type: feedType},
} = options;
const feed = await generateBlogFeed(context, options);
if (!feed) {
return;
}
let feedTypes = [];
if (feedType === 'all') {
feedTypes = ['rss', 'atom'];
} else {
feedTypes.push(feedType);
}
await Promise.all(
feedTypes.map(feedType => {
const feedPath = path.join(
outDir,
options.routeBasePath,
`${feedType}.xml`,
);
const feedContent = feedType === 'rss' ? feed.rss2() : feed.atom1();
return fs.writeFile(feedPath, feedContent, err => {
if (err) {
throw new Error(`Generating ${feedType} feed failed: ${err}`);
}
});
}),
);
},
}; };
} }

View file

@ -7,24 +7,19 @@
const {parseQuery, getOptions} = require('loader-utils'); const {parseQuery, getOptions} = require('loader-utils');
import {loader} from 'webpack'; import {loader} from 'webpack';
import {truncate} from './blogUtils';
export = function(fileString: string) { export = function(fileString: string) {
const callback = this.async(); const callback = this.async();
const {truncateMarker} = getOptions(this); const {truncateMarker}: {truncateMarker: RegExp | string} = getOptions(this);
let finalContent = fileString; let finalContent = fileString;
// Truncate content if requested (e.g: file.md?truncated=true) // Truncate content if requested (e.g: file.md?truncated=true)
const {truncated} = this.resourceQuery && parseQuery(this.resourceQuery); const {truncated} = this.resourceQuery && parseQuery(this.resourceQuery);
if ( if (truncated) {
truncated && finalContent = truncate(fileString, truncateMarker);
(typeof truncateMarker === 'string'
? fileString.includes(truncateMarker)
: truncateMarker.test(fileString))
) {
// eslint-disable-next-line
finalContent = fileString.split(truncateMarker)[0];
} }
return callback && callback(null, finalContent); return callback && callback(null, finalContent);
} as loader.Loader; } as loader.Loader;

View file

@ -22,6 +22,13 @@ export interface PluginOptions {
remarkPlugins: string[]; remarkPlugins: string[];
rehypePlugins: string[]; rehypePlugins: string[];
truncateMarker: RegExp | string; truncateMarker: RegExp | string;
feedOptions?: {
type: 'rss' | 'atom' | 'all';
title?: string;
description?: string;
copyright: string;
language?: string;
};
} }
export interface BlogTags { export interface BlogTags {

View file

@ -93,6 +93,21 @@ module.exports = {
*/ */
remarkPlugins: [], remarkPlugins: [],
rehypePlugins: [], rehypePlugins: [],
/**
* Truncate marker, can be a regex or string.
*/
truncateMarker: /<!--\s*(truncate)\s*-->/
/**
* Blog feed
* If feedOptions is undefined, no rss feed will be generated
*/
feedOptions: {
type: '', // required. 'rss' | 'feed' | 'all'
title: '', // default to siteConfig.title
description: '', // default to `${siteConfig.title} Blog`
copyright: '',
language: undefined; // possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
};
}, },
], ],
], ],

View file

@ -74,6 +74,42 @@ Not this.
Or this. Or this.
``` ```
## Feed
You can generate RSS/ Atom feed by passing feedOptions.
```ts
feedOptions?: {
type: 'rss' | 'atom' | 'all';
title?: string;
description?: string;
copyright: string;
language?: string; // possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
};
```
Example usage:
```js {9-12}
// docusaurus.config.js
module.exports = {
// ...
presets: [
[
'@docusaurus/preset-classic',
{
blog: {
feedOptions: {
type: 'all',
copyright: `Copyright © ${new Date().getFullYear()} Facebook, Inc.`
},
},
},
],
],
};
```
## Advanced topics ## Advanced topics
### Blog-only mode ### Blog-only mode

View file

@ -40,6 +40,10 @@ module.exports = {
blog: { blog: {
path: '../website-1.x/blog', path: '../website-1.x/blog',
postsPerPage: 3, postsPerPage: 3,
feedOptions: {
type: 'all',
copyright: `Copyright © ${new Date().getFullYear()} Facebook, Inc.`,
},
}, },
theme: { theme: {
customCss: require.resolve('./src/css/custom.css'), customCss: require.resolve('./src/css/custom.css'),

View file

@ -6936,6 +6936,13 @@ feed@^1.1.0:
dependencies: dependencies:
xml "^1.0.1" xml "^1.0.1"
feed@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/feed/-/feed-4.0.0.tgz#d3c2bfb9aba3c0efacab1ed15be06b67f5c02539"
integrity sha512-VWtvINgG7cA91BtrGychMvxHj84nc8xS9W/PuAHlY62I8owZtcoxNaKFN+zkGl8tBsaYbxrrp4yB9DhqKNQSPw==
dependencies:
xml-js "^1.6.11"
figgy-pudding@^3.4.1, figgy-pudding@^3.5.1: figgy-pudding@^3.4.1, figgy-pudding@^3.5.1:
version "3.5.1" version "3.5.1"
resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790"
@ -16515,6 +16522,13 @@ xhr@^2.0.1:
parse-headers "^2.0.0" parse-headers "^2.0.0"
xtend "^4.0.0" xtend "^4.0.0"
xml-js@^1.6.11:
version "1.6.11"
resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9"
integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==
dependencies:
sax "^1.2.4"
xml-name-validator@^3.0.0: xml-name-validator@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"