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: add createSitemapItems hook #10083

Merged
merged 8 commits into from
Apr 30, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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[]>;