Skip to content

Commit

Permalink
feat: add createSitemapItems hook (#10083)
Browse files Browse the repository at this point in the history
Co-authored-by: Sébastien Lorber <slorber@users.noreply.github.com>
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
  • Loading branch information
3 people committed Apr 30, 2024
1 parent be9081a commit 7057ba4
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 71 deletions.
Expand Up @@ -5,7 +5,7 @@
* 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 createSitemap from '../createSitemap';
import type {PluginOptions} from '../options';
Expand Down Expand Up @@ -84,6 +84,53 @@ describe('createSitemap', () => {
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 () => {
const sitemap = await createSitemap({
siteConfig,
Expand Down Expand Up @@ -140,7 +187,7 @@ describe('createSitemap', () => {
meta: {
// @ts-expect-error: bad lib def
toComponent: () => [
React.createElement('meta', {
createElement('meta', {
name: 'robots',
content: 'NoFolloW, NoiNDeX',
}),
Expand All @@ -164,15 +211,15 @@ describe('createSitemap', () => {
meta: {
// @ts-expect-error: bad lib def
toComponent: () => [
React.createElement('meta', {name: 'robots', content: 'noindex'}),
createElement('meta', {name: 'robots', content: 'noindex'}),
],
},
},
'/noindex': {
meta: {
// @ts-expect-error: bad lib def
toComponent: () => [
React.createElement('meta', {name: 'robots', content: 'noindex'}),
createElement('meta', {name: 'robots', content: 'noindex'}),
],
},
},
Expand Down
40 changes: 40 additions & 0 deletions packages/docusaurus-plugin-sitemap/src/__tests__/options.test.ts
Expand Up @@ -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"`,
);
});
});
});
114 changes: 48 additions & 66 deletions packages/docusaurus-plugin-sitemap/src/createSitemap.ts
Expand Up @@ -5,57 +5,14 @@
* LICENSE file in the root directory of this source tree.
*/

import type {ReactElement} from 'react';
import {createMatcher, flattenRoutes} from '@docusaurus/utils';
import {sitemapItemsToXmlString} from './xml';
import {createSitemapItem} from './createSitemapItem';
import type {SitemapItem} from './types';
import type {DocusaurusConfig, RouteConfig} from '@docusaurus/types';
import type {HelmetServerState} from 'react-helmet-async';
import {isNoIndexMetaRoute} from './head';
import type {CreateSitemapItemsFn, CreateSitemapItemsParams} from './types';
import type {RouteConfig} from '@docusaurus/types';
import type {PluginOptions} from './options';

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}),
);
}
import type {HelmetServerState} from 'react-helmet-async';

// Not all routes should appear in the sitemap, and we should filter:
// - parent routes, used for layouts
Expand All @@ -75,32 +32,57 @@ function getSitemapRoutes({routes, head, options}: CreateSitemapParams) {
return flattenRoutes(routes).filter((route) => !isRouteExcluded(route));
}

async function createSitemapItems(
params: CreateSitemapParams,
): Promise<SitemapItem[]> {
const sitemapRoutes = getSitemapRoutes(params);
if (sitemapRoutes.length === 0) {
return [];
}
return Promise.all(
sitemapRoutes.map((route) =>
createSitemapItem({
route,
siteConfig: params.siteConfig,
options: params.options,
}),
),
);
// Our default implementation receives some additional parameters on purpose
// Params such as "head" are "messy" and not directly exposed to the user
function createDefaultCreateSitemapItems(
internalParams: Pick<CreateSitemapParams, 'head' | 'options'>,
): CreateSitemapItemsFn {
return async (params) => {
const sitemapRoutes = getSitemapRoutes({...params, ...internalParams});
if (sitemapRoutes.length === 0) {
return [];
}
return Promise.all(
sitemapRoutes.map((route) =>
createSitemapItem({
route,
siteConfig: params.siteConfig,
options: internalParams.options,
}),
),
);
};
}

type CreateSitemapParams = CreateSitemapItemsParams & {
head: {[location: string]: HelmetServerState};
options: PluginOptions;
};

export default async function createSitemap(
params: CreateSitemapParams,
): Promise<string | null> {
const items = await createSitemapItems(params);
if (items.length === 0) {
const {head, options, routes, siteConfig} = params;

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;
}
const xmlString = await sitemapItemsToXmlString(items, {

const xmlString = await sitemapItemsToXmlString(sitemapItems, {
lastmod: params.options.lastmod,
});
return xmlString;
Expand Down
47 changes: 47 additions & 0 deletions packages/docusaurus-plugin-sitemap/src/head.ts
@@ -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
);
}
19 changes: 18 additions & 1 deletion packages/docusaurus-plugin-sitemap/src/options.ts
Expand Up @@ -8,7 +8,13 @@
import {Joi} from '@docusaurus/utils-validation';
import {ChangeFreqList, LastModOptionList} from './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 = {
/**
Expand Down Expand Up @@ -44,8 +50,17 @@ export type PluginOptions = {
* @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
*/
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 const DEFAULT_OPTIONS: PluginOptions = {
Expand Down Expand Up @@ -90,6 +105,8 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
.valid(null, ...LastModOptionList)
.default(DEFAULT_OPTIONS.lastmod),

createSitemapItems: Joi.function(),

ignorePatterns: Joi.array()
.items(Joi.string())
.default(DEFAULT_OPTIONS.ignorePatterns),
Expand Down
11 changes: 11 additions & 0 deletions packages/docusaurus-plugin-sitemap/src/types.ts
Expand Up @@ -5,6 +5,8 @@
* 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 type LastModOption = (typeof LastModOptionList)[number];
Expand Down Expand Up @@ -65,3 +67,12 @@ export type SitemapItem = {
*/
priority?: number | null;
};

export type CreateSitemapItemsParams = {
siteConfig: DocusaurusConfig;
routes: RouteConfig[];
};

export type CreateSitemapItemsFn = (
params: CreateSitemapItemsParams,
) => Promise<SitemapItem[]>;

0 comments on commit 7057ba4

Please sign in to comment.