feat: add createSitemapItems hook (#10083)

Co-authored-by: Sébastien Lorber <slorber@users.noreply.github.com>
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
This commit is contained in:
John Reilly 2024-04-30 20:20:54 +01:00 committed by GitHub
parent be9081afc7
commit 7057ba4ce8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 233 additions and 71 deletions

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import React from 'react'; import {createElement} from 'react';
import {fromPartial} from '@total-typescript/shoehorn'; import {fromPartial} from '@total-typescript/shoehorn';
import createSitemap from '../createSitemap'; import createSitemap from '../createSitemap';
import type {PluginOptions} from '../options'; import type {PluginOptions} from '../options';
@ -84,6 +84,53 @@ describe('createSitemap', () => {
expect(sitemap).not.toContain('/tags'); expect(sitemap).not.toContain('/tags');
}); });
it('excludes items that createSitemapItems configures to be ignored', async () => {
const sitemap = await createSitemap({
siteConfig,
routes: routes([
'/',
'/search/',
'/tags/',
'/search/foo',
'/tags/foo/bar',
]),
head: {},
options: {
...options,
createSitemapItems: async (params) => {
const {defaultCreateSitemapItems, ...rest} = params;
const sitemapItems = await defaultCreateSitemapItems(rest);
const sitemapsWithoutPageAndTags = sitemapItems.filter(
(sitemapItem) =>
!sitemapItem.url.includes('/tags/') &&
!sitemapItem.url.endsWith('/search/'),
);
return sitemapsWithoutPageAndTags;
},
},
});
expect(sitemap).not.toContain('/search/</loc>');
expect(sitemap).toContain('/search/foo');
expect(sitemap).not.toContain('/tags');
});
it('returns null when createSitemapItems returns no items', async () => {
const sitemap = await createSitemap({
siteConfig,
routes: routes(['/', '/docs/myDoc/', '/blog/post']),
head: {},
options: {
...options,
createSitemapItems: async () => {
return [];
},
},
});
expect(sitemap).toBeNull();
});
it('keep trailing slash unchanged', async () => { it('keep trailing slash unchanged', async () => {
const sitemap = await createSitemap({ const sitemap = await createSitemap({
siteConfig, siteConfig,
@ -140,7 +187,7 @@ describe('createSitemap', () => {
meta: { meta: {
// @ts-expect-error: bad lib def // @ts-expect-error: bad lib def
toComponent: () => [ toComponent: () => [
React.createElement('meta', { createElement('meta', {
name: 'robots', name: 'robots',
content: 'NoFolloW, NoiNDeX', content: 'NoFolloW, NoiNDeX',
}), }),
@ -164,7 +211,7 @@ describe('createSitemap', () => {
meta: { meta: {
// @ts-expect-error: bad lib def // @ts-expect-error: bad lib def
toComponent: () => [ toComponent: () => [
React.createElement('meta', {name: 'robots', content: 'noindex'}), createElement('meta', {name: 'robots', content: 'noindex'}),
], ],
}, },
}, },
@ -172,7 +219,7 @@ describe('createSitemap', () => {
meta: { meta: {
// @ts-expect-error: bad lib def // @ts-expect-error: bad lib def
toComponent: () => [ toComponent: () => [
React.createElement('meta', {name: 'robots', content: 'noindex'}), createElement('meta', {name: 'robots', content: 'noindex'}),
], ],
}, },
}, },

View file

@ -249,4 +249,44 @@ describe('validateOptions', () => {
); );
}); });
}); });
describe('createSitemapItems', () => {
it('accept createSitemapItems undefined', () => {
const userOptions: Options = {
createSitemapItems: undefined,
};
expect(testValidate(userOptions)).toEqual(defaultOptions);
});
it('accept createSitemapItems valid', () => {
const userOptions: Options = {
createSitemapItems: async (params) => {
const {defaultCreateSitemapItems, ...rest} = params;
const sitemapItems = await defaultCreateSitemapItems(rest);
const sitemapsWithoutPageAndTags = sitemapItems.filter(
(sitemapItem) =>
!sitemapItem.url.includes('/tags/') &&
!sitemapItem.url.includes('/page/'),
);
return sitemapsWithoutPageAndTags;
},
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('rejects createSitemapItems bad input type', () => {
const userOptions: Options = {
// @ts-expect-error: test
createSitemapItems: 'not a function',
};
expect(() =>
testValidate(userOptions),
).toThrowErrorMatchingInlineSnapshot(
`""createSitemapItems" must be of type function"`,
);
});
});
}); });

View file

@ -5,57 +5,14 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import type {ReactElement} from 'react';
import {createMatcher, flattenRoutes} from '@docusaurus/utils'; import {createMatcher, flattenRoutes} from '@docusaurus/utils';
import {sitemapItemsToXmlString} from './xml'; import {sitemapItemsToXmlString} from './xml';
import {createSitemapItem} from './createSitemapItem'; import {createSitemapItem} from './createSitemapItem';
import type {SitemapItem} from './types'; import {isNoIndexMetaRoute} from './head';
import type {DocusaurusConfig, RouteConfig} from '@docusaurus/types'; import type {CreateSitemapItemsFn, CreateSitemapItemsParams} from './types';
import type {HelmetServerState} from 'react-helmet-async'; import type {RouteConfig} from '@docusaurus/types';
import type {PluginOptions} from './options'; import type {PluginOptions} from './options';
import type {HelmetServerState} from 'react-helmet-async';
type CreateSitemapParams = {
siteConfig: DocusaurusConfig;
routes: RouteConfig[];
head: {[location: string]: HelmetServerState};
options: PluginOptions;
};
// Maybe we want to add a routeConfig.metadata.noIndex instead?
// But using Helmet is more reliable for third-party plugins...
function isNoIndexMetaRoute({
head,
route,
}: {
head: {[location: string]: HelmetServerState};
route: string;
}) {
const isNoIndexMetaTag = ({
name,
content,
}: {
name?: string;
content?: string;
}): boolean => {
if (!name || !content) {
return false;
}
return (
// meta name is not case-sensitive
name.toLowerCase() === 'robots' &&
// Robots directives are not case-sensitive
content.toLowerCase().includes('noindex')
);
};
// https://github.com/staylor/react-helmet-async/pull/167
const meta = head[route]?.meta.toComponent() as unknown as
| ReactElement<{name?: string; content?: string}>[]
| undefined;
return meta?.some((tag) =>
isNoIndexMetaTag({name: tag.props.name, content: tag.props.content}),
);
}
// Not all routes should appear in the sitemap, and we should filter: // Not all routes should appear in the sitemap, and we should filter:
// - parent routes, used for layouts // - parent routes, used for layouts
@ -75,32 +32,57 @@ function getSitemapRoutes({routes, head, options}: CreateSitemapParams) {
return flattenRoutes(routes).filter((route) => !isRouteExcluded(route)); return flattenRoutes(routes).filter((route) => !isRouteExcluded(route));
} }
async function createSitemapItems( // Our default implementation receives some additional parameters on purpose
params: CreateSitemapParams, // Params such as "head" are "messy" and not directly exposed to the user
): Promise<SitemapItem[]> { function createDefaultCreateSitemapItems(
const sitemapRoutes = getSitemapRoutes(params); internalParams: Pick<CreateSitemapParams, 'head' | 'options'>,
if (sitemapRoutes.length === 0) { ): CreateSitemapItemsFn {
return []; return async (params) => {
} const sitemapRoutes = getSitemapRoutes({...params, ...internalParams});
return Promise.all( if (sitemapRoutes.length === 0) {
sitemapRoutes.map((route) => return [];
createSitemapItem({ }
route, return Promise.all(
siteConfig: params.siteConfig, sitemapRoutes.map((route) =>
options: params.options, createSitemapItem({
}), route,
), siteConfig: params.siteConfig,
); options: internalParams.options,
}),
),
);
};
} }
type CreateSitemapParams = CreateSitemapItemsParams & {
head: {[location: string]: HelmetServerState};
options: PluginOptions;
};
export default async function createSitemap( export default async function createSitemap(
params: CreateSitemapParams, params: CreateSitemapParams,
): Promise<string | null> { ): Promise<string | null> {
const items = await createSitemapItems(params); const {head, options, routes, siteConfig} = params;
if (items.length === 0) {
const defaultCreateSitemapItems: CreateSitemapItemsFn =
createDefaultCreateSitemapItems({head, options});
const sitemapItems = params.options.createSitemapItems
? await params.options.createSitemapItems({
routes,
siteConfig,
defaultCreateSitemapItems,
})
: await defaultCreateSitemapItems({
routes,
siteConfig,
});
if (sitemapItems.length === 0) {
return null; return null;
} }
const xmlString = await sitemapItemsToXmlString(items, {
const xmlString = await sitemapItemsToXmlString(sitemapItems, {
lastmod: params.options.lastmod, lastmod: params.options.lastmod,
}); });
return xmlString; return xmlString;

View file

@ -0,0 +1,47 @@
/**
* 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 type {ReactElement} from 'react';
import type {HelmetServerState} from 'react-helmet-async';
// Maybe we want to add a routeConfig.metadata.noIndex instead?
// But using Helmet is more reliable for third-party plugins...
export function isNoIndexMetaRoute({
head,
route,
}: {
head: {[location: string]: HelmetServerState};
route: string;
}): boolean {
const isNoIndexMetaTag = ({
name,
content,
}: {
name?: string;
content?: string;
}): boolean => {
if (!name || !content) {
return false;
}
return (
// meta name is not case-sensitive
name.toLowerCase() === 'robots' &&
// Robots directives are not case-sensitive
content.toLowerCase().includes('noindex')
);
};
// https://github.com/staylor/react-helmet-async/pull/167
const meta = head[route]?.meta.toComponent() as unknown as
| ReactElement<{name?: string; content?: string}>[]
| undefined;
return (
meta?.some((tag) =>
isNoIndexMetaTag({name: tag.props.name, content: tag.props.content}),
) ?? false
);
}

View file

@ -8,7 +8,13 @@
import {Joi} from '@docusaurus/utils-validation'; import {Joi} from '@docusaurus/utils-validation';
import {ChangeFreqList, LastModOptionList} from './types'; import {ChangeFreqList, LastModOptionList} from './types';
import type {OptionValidationContext} from '@docusaurus/types'; import type {OptionValidationContext} from '@docusaurus/types';
import type {ChangeFreq, LastModOption} from './types'; import type {
ChangeFreq,
LastModOption,
SitemapItem,
CreateSitemapItemsFn,
CreateSitemapItemsParams,
} from './types';
export type PluginOptions = { export type PluginOptions = {
/** /**
@ -44,8 +50,17 @@ export type PluginOptions = {
* @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions * @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
*/ */
priority: number | null; priority: number | null;
/** Allow control over the construction of SitemapItems */
createSitemapItems?: CreateSitemapItemsOption;
}; };
type CreateSitemapItemsOption = (
params: CreateSitemapItemsParams & {
defaultCreateSitemapItems: CreateSitemapItemsFn;
},
) => Promise<SitemapItem[]>;
export type Options = Partial<PluginOptions>; export type Options = Partial<PluginOptions>;
export const DEFAULT_OPTIONS: PluginOptions = { export const DEFAULT_OPTIONS: PluginOptions = {
@ -90,6 +105,8 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
.valid(null, ...LastModOptionList) .valid(null, ...LastModOptionList)
.default(DEFAULT_OPTIONS.lastmod), .default(DEFAULT_OPTIONS.lastmod),
createSitemapItems: Joi.function(),
ignorePatterns: Joi.array() ignorePatterns: Joi.array()
.items(Joi.string()) .items(Joi.string())
.default(DEFAULT_OPTIONS.ignorePatterns), .default(DEFAULT_OPTIONS.ignorePatterns),

View file

@ -5,6 +5,8 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import type {DocusaurusConfig, RouteConfig} from '@docusaurus/types';
export const LastModOptionList = ['date', 'datetime'] as const; export const LastModOptionList = ['date', 'datetime'] as const;
export type LastModOption = (typeof LastModOptionList)[number]; export type LastModOption = (typeof LastModOptionList)[number];
@ -65,3 +67,12 @@ export type SitemapItem = {
*/ */
priority?: number | null; priority?: number | null;
}; };
export type CreateSitemapItemsParams = {
siteConfig: DocusaurusConfig;
routes: RouteConfig[];
};
export type CreateSitemapItemsFn = (
params: CreateSitemapItemsParams,
) => Promise<SitemapItem[]>;

View file

@ -44,11 +44,24 @@ Accepted fields:
| `priority` | `number \| null` | `0.5` | See [sitemap docs](https://www.sitemaps.org/protocol.html#xmlTagDefinitions) | | `priority` | `number \| null` | `0.5` | See [sitemap docs](https://www.sitemaps.org/protocol.html#xmlTagDefinitions) |
| `ignorePatterns` | `string[]` | `[]` | A list of glob patterns; matching route paths will be filtered from the sitemap. Note that you may need to include the base URL in here. | | `ignorePatterns` | `string[]` | `[]` | A list of glob patterns; matching route paths will be filtered from the sitemap. Note that you may need to include the base URL in here. |
| `filename` | `string` | `sitemap.xml` | The path to the created sitemap file, relative to the output directory. Useful if you have two plugin instances outputting two files. | | `filename` | `string` | `sitemap.xml` | The path to the created sitemap file, relative to the output directory. Useful if you have two plugin instances outputting two files. |
| `createSitemapItems` | <code>[CreateSitemapItemsFn](#CreateSitemapItemsFn) \| undefined</code> | `undefined` | An optional function which can be used to transform and / or filter the items in the sitemap. |
```mdx-code-block ```mdx-code-block
</APITable> </APITable>
``` ```
### Types {#types}
#### `CreateSitemapItemsFn` {#CreateSitemapItemsFn}
```ts
type CreateSitemapItemsFn = (params: {
siteConfig: DocusaurusConfig;
routes: RouteConfig[];
defaultCreateSitemapItems: CreateSitemapItemsFn;
}) => Promise<SitemapItem[]>;
```
:::info :::info
This plugin also respects some site config: This plugin also respects some site config:
@ -86,6 +99,11 @@ const config = {
priority: 0.5, priority: 0.5,
ignorePatterns: ['/tags/**'], ignorePatterns: ['/tags/**'],
filename: 'sitemap.xml', filename: 'sitemap.xml',
createSitemapItems: async (params) => {
const {defaultCreateSitemapItems, ...rest} = params;
const items = await defaultCreateSitemapItems(rest);
return items.filter((item) => !item.url.includes('/page/'));
},
}; };
``` ```