@docusaurus/plugin-sitemap 3.1.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,16 @@
4
4
  * This source code is licensed under the MIT license found in the
5
5
  * LICENSE file in the root directory of this source tree.
6
6
  */
7
- import type { DocusaurusConfig } from '@docusaurus/types';
7
+ import type { DocusaurusConfig, RouteConfig } from '@docusaurus/types';
8
8
  import type { HelmetServerState } from 'react-helmet-async';
9
9
  import type { PluginOptions } from './options';
10
- export default function createSitemap(siteConfig: DocusaurusConfig, routesPaths: string[], head: {
11
- [location: string]: HelmetServerState;
12
- }, options: PluginOptions): Promise<string | null>;
10
+ type CreateSitemapParams = {
11
+ siteConfig: DocusaurusConfig;
12
+ routes: RouteConfig[];
13
+ head: {
14
+ [location: string]: HelmetServerState;
15
+ };
16
+ options: PluginOptions;
17
+ };
18
+ export default function createSitemap(params: CreateSitemapParams): Promise<string | null>;
19
+ export {};
@@ -6,9 +6,11 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- const sitemap_1 = require("sitemap");
10
- const utils_common_1 = require("@docusaurus/utils-common");
11
9
  const utils_1 = require("@docusaurus/utils");
10
+ const xml_1 = require("./xml");
11
+ const createSitemapItem_1 = require("./createSitemapItem");
12
+ // Maybe we want to add a routeConfig.metadata.noIndex instead?
13
+ // But using Helmet is more reliable for third-party plugins...
12
14
  function isNoIndexMetaRoute({ head, route, }) {
13
15
  const isNoIndexMetaTag = ({ name, content, }) => {
14
16
  if (!name || !content) {
@@ -24,33 +26,37 @@ function isNoIndexMetaRoute({ head, route, }) {
24
26
  const meta = head[route]?.meta.toComponent();
25
27
  return meta?.some((tag) => isNoIndexMetaTag({ name: tag.props.name, content: tag.props.content }));
26
28
  }
27
- async function createSitemap(siteConfig, routesPaths, head, options) {
28
- const { url: hostname } = siteConfig;
29
- if (!hostname) {
30
- throw new Error('URL in docusaurus.config.js cannot be empty/undefined.');
31
- }
32
- const { changefreq, priority, ignorePatterns } = options;
29
+ // Not all routes should appear in the sitemap, and we should filter:
30
+ // - parent routes, used for layouts
31
+ // - routes matching options.ignorePatterns
32
+ // - routes with no index metadata
33
+ function getSitemapRoutes({ routes, head, options }) {
34
+ const { ignorePatterns } = options;
33
35
  const ignoreMatcher = (0, utils_1.createMatcher)(ignorePatterns);
34
36
  function isRouteExcluded(route) {
35
- return (route.endsWith('404.html') ||
36
- ignoreMatcher(route) ||
37
- isNoIndexMetaRoute({ head, route }));
37
+ return (ignoreMatcher(route.path) || isNoIndexMetaRoute({ head, route: route.path }));
38
+ }
39
+ return (0, utils_1.flattenRoutes)(routes).filter((route) => !isRouteExcluded(route));
40
+ }
41
+ async function createSitemapItems(params) {
42
+ const sitemapRoutes = getSitemapRoutes(params);
43
+ if (sitemapRoutes.length === 0) {
44
+ return [];
38
45
  }
39
- const includedRoutes = routesPaths.filter((route) => !isRouteExcluded(route));
40
- if (includedRoutes.length === 0) {
46
+ return Promise.all(sitemapRoutes.map((route) => (0, createSitemapItem_1.createSitemapItem)({
47
+ route,
48
+ siteConfig: params.siteConfig,
49
+ options: params.options,
50
+ })));
51
+ }
52
+ async function createSitemap(params) {
53
+ const items = await createSitemapItems(params);
54
+ if (items.length === 0) {
41
55
  return null;
42
56
  }
43
- const sitemapStream = new sitemap_1.SitemapStream({ hostname });
44
- includedRoutes.forEach((routePath) => sitemapStream.write({
45
- url: (0, utils_common_1.applyTrailingSlash)(routePath, {
46
- trailingSlash: siteConfig.trailingSlash,
47
- baseUrl: siteConfig.baseUrl,
48
- }),
49
- changefreq,
50
- priority,
51
- }));
52
- sitemapStream.end();
53
- const generatedSitemap = (await (0, sitemap_1.streamToPromise)(sitemapStream)).toString();
54
- return generatedSitemap;
57
+ const xmlString = await (0, xml_1.sitemapItemsToXmlString)(items, {
58
+ lastmod: params.options.lastmod,
59
+ });
60
+ return xmlString;
55
61
  }
56
62
  exports.default = createSitemap;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import type { SitemapItem } from './types';
8
+ import type { DocusaurusConfig, RouteConfig } from '@docusaurus/types';
9
+ import type { PluginOptions } from './options';
10
+ export declare function createSitemapItem({ route, siteConfig, options, }: {
11
+ route: RouteConfig;
12
+ siteConfig: DocusaurusConfig;
13
+ options: PluginOptions;
14
+ }): Promise<SitemapItem>;
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) Facebook, Inc. and its affiliates.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.createSitemapItem = void 0;
10
+ const utils_common_1 = require("@docusaurus/utils-common");
11
+ const utils_1 = require("@docusaurus/utils");
12
+ async function getRouteLastUpdatedAt(route) {
13
+ if (route.metadata?.lastUpdatedAt) {
14
+ return route.metadata?.lastUpdatedAt;
15
+ }
16
+ if (route.metadata?.sourceFilePath) {
17
+ const lastUpdate = await (0, utils_1.getLastUpdate)(route.metadata?.sourceFilePath);
18
+ return lastUpdate?.lastUpdatedAt;
19
+ }
20
+ return undefined;
21
+ }
22
+ const LastmodFormatters = {
23
+ date: (timestamp) => new Date(timestamp).toISOString().split('T')[0],
24
+ datetime: (timestamp) => new Date(timestamp).toISOString(),
25
+ };
26
+ function formatLastmod(timestamp, lastmodOption) {
27
+ const format = LastmodFormatters[lastmodOption];
28
+ return format(timestamp);
29
+ }
30
+ async function getRouteLastmod({ route, lastmod, }) {
31
+ if (lastmod === null) {
32
+ return null;
33
+ }
34
+ const lastUpdatedAt = (await getRouteLastUpdatedAt(route)) ?? null;
35
+ return lastUpdatedAt ? formatLastmod(lastUpdatedAt, lastmod) : null;
36
+ }
37
+ async function createSitemapItem({ route, siteConfig, options, }) {
38
+ const { changefreq, priority, lastmod } = options;
39
+ return {
40
+ url: (0, utils_1.normalizeUrl)([
41
+ siteConfig.url,
42
+ (0, utils_common_1.applyTrailingSlash)(route.path, {
43
+ trailingSlash: siteConfig.trailingSlash,
44
+ baseUrl: siteConfig.baseUrl,
45
+ }),
46
+ ]),
47
+ changefreq,
48
+ priority,
49
+ lastmod: await getRouteLastmod({ route, lastmod }),
50
+ };
51
+ }
52
+ exports.createSitemapItem = createSitemapItem;
package/lib/index.js CHANGED
@@ -15,12 +15,17 @@ const createSitemap_1 = tslib_1.__importDefault(require("./createSitemap"));
15
15
  function pluginSitemap(context, options) {
16
16
  return {
17
17
  name: 'docusaurus-plugin-sitemap',
18
- async postBuild({ siteConfig, routesPaths, outDir, head }) {
18
+ async postBuild({ siteConfig, routes, outDir, head }) {
19
19
  if (siteConfig.noIndex) {
20
20
  return;
21
21
  }
22
22
  // Generate sitemap.
23
- const generatedSitemap = await (0, createSitemap_1.default)(siteConfig, routesPaths, head, options);
23
+ const generatedSitemap = await (0, createSitemap_1.default)({
24
+ siteConfig,
25
+ routes,
26
+ head,
27
+ options,
28
+ });
24
29
  if (!generatedSitemap) {
25
30
  return;
26
31
  }
package/lib/options.d.ts CHANGED
@@ -4,23 +4,38 @@
4
4
  * This source code is licensed under the MIT license found in the
5
5
  * LICENSE file in the root directory of this source tree.
6
6
  */
7
- import { EnumChangefreq } from 'sitemap';
8
7
  import type { OptionValidationContext } from '@docusaurus/types';
8
+ import type { ChangeFreq, LastModOption } from './types';
9
9
  export type PluginOptions = {
10
- /** @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions */
11
- changefreq: EnumChangefreq;
12
- /** @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions */
13
- priority: number;
10
+ /**
11
+ * The path to the created sitemap file, relative to the output directory.
12
+ * Useful if you have two plugin instances outputting two files.
13
+ */
14
+ filename: string;
14
15
  /**
15
16
  * A list of glob patterns; matching route paths will be filtered from the
16
17
  * sitemap. Note that you may need to include the base URL in here.
17
18
  */
18
19
  ignorePatterns: string[];
19
20
  /**
20
- * The path to the created sitemap file, relative to the output directory.
21
- * Useful if you have two plugin instances outputting two files.
21
+ * Defines the format of the "lastmod" sitemap item entry, between:
22
+ * - null: do not compute/add a "lastmod" sitemap entry
23
+ * - "date": add a "lastmod" sitemap entry without time (YYYY-MM-DD)
24
+ * - "datetime": add a "lastmod" sitemap entry with time (ISO 8601 datetime)
25
+ * @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
26
+ * @see https://www.w3.org/TR/NOTE-datetime
22
27
  */
23
- filename: string;
28
+ lastmod: LastModOption | null;
29
+ /**
30
+ * TODO Docusaurus v4 breaking change: remove useless option
31
+ * @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
32
+ */
33
+ changefreq: ChangeFreq | null;
34
+ /**
35
+ * TODO Docusaurus v4 breaking change: remove useless option
36
+ * @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
37
+ */
38
+ priority: number | null;
24
39
  };
25
40
  export type Options = Partial<PluginOptions>;
26
41
  export declare const DEFAULT_OPTIONS: PluginOptions;
package/lib/options.js CHANGED
@@ -8,22 +8,41 @@
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.validateOptions = exports.DEFAULT_OPTIONS = void 0;
10
10
  const utils_validation_1 = require("@docusaurus/utils-validation");
11
- const sitemap_1 = require("sitemap");
11
+ const types_1 = require("./types");
12
12
  exports.DEFAULT_OPTIONS = {
13
- changefreq: sitemap_1.EnumChangefreq.WEEKLY,
14
- priority: 0.5,
15
- ignorePatterns: [],
16
13
  filename: 'sitemap.xml',
14
+ ignorePatterns: [],
15
+ // TODO Docusaurus v4 breaking change
16
+ // change default to "date" if no bug or perf issue reported
17
+ lastmod: null,
18
+ // TODO Docusaurus v4 breaking change
19
+ // those options are useless and should be removed
20
+ changefreq: 'weekly',
21
+ priority: 0.5,
17
22
  };
18
23
  const PluginOptionSchema = utils_validation_1.Joi.object({
19
24
  // @ts-expect-error: forbidden
20
25
  cacheTime: utils_validation_1.Joi.forbidden().messages({
21
26
  'any.unknown': 'Option `cacheTime` in sitemap config is deprecated. Please remove it.',
22
27
  }),
28
+ // TODO remove for Docusaurus v4 breaking changes?
29
+ // This is not even used by Google crawlers
30
+ // See also https://github.com/facebook/docusaurus/issues/2604
23
31
  changefreq: utils_validation_1.Joi.string()
24
- .valid(...Object.values(sitemap_1.EnumChangefreq))
32
+ .valid(null, ...types_1.ChangeFreqList)
25
33
  .default(exports.DEFAULT_OPTIONS.changefreq),
26
- priority: utils_validation_1.Joi.number().min(0).max(1).default(exports.DEFAULT_OPTIONS.priority),
34
+ // TODO remove for Docusaurus v4 breaking changes?
35
+ // This is not even used by Google crawlers
36
+ // The priority is "relative", and using the same priority for all routes
37
+ // does not make sense according to the spec
38
+ // See also https://github.com/facebook/docusaurus/issues/2604
39
+ // See also https://www.sitemaps.org/protocol.html
40
+ priority: utils_validation_1.Joi.alternatives()
41
+ .try(utils_validation_1.Joi.valid(null), utils_validation_1.Joi.number().min(0).max(1))
42
+ .default(exports.DEFAULT_OPTIONS.priority),
43
+ lastmod: utils_validation_1.Joi.string()
44
+ .valid(null, ...types_1.LastModOptionList)
45
+ .default(exports.DEFAULT_OPTIONS.lastmod),
27
46
  ignorePatterns: utils_validation_1.Joi.array()
28
47
  .items(utils_validation_1.Joi.string())
29
48
  .default(exports.DEFAULT_OPTIONS.ignorePatterns),
package/lib/types.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ export declare const LastModOptionList: readonly ["date", "datetime"];
8
+ export type LastModOption = (typeof LastModOptionList)[number];
9
+ export declare const ChangeFreqList: readonly ["hourly", "daily", "weekly", "monthly", "yearly", "always", "never"];
10
+ export type ChangeFreq = (typeof ChangeFreqList)[number];
11
+ export type SitemapItem = {
12
+ /**
13
+ * URL of the page.
14
+ * This URL must begin with the protocol (such as http).
15
+ * It should eventually end with a trailing slash.
16
+ * It should be less than 2,048 characters.
17
+ */
18
+ url: string;
19
+ /**
20
+ * ISO 8601 date string.
21
+ * See also https://www.w3.org/TR/NOTE-datetime
22
+ *
23
+ * It is recommended to use one of:
24
+ * - date.toISOString()
25
+ * - YYYY-MM-DD
26
+ *
27
+ * Note: as of 2024, Google uses this value for crawling priority.
28
+ * See also https://github.com/facebook/docusaurus/issues/2604
29
+ */
30
+ lastmod?: string | null;
31
+ /**
32
+ * One of the specified enum values
33
+ *
34
+ * Note: as of 2024, Google ignores this value.
35
+ * See also https://github.com/facebook/docusaurus/issues/2604
36
+ */
37
+ changefreq?: ChangeFreq | null;
38
+ /**
39
+ * The priority of this URL relative to other URLs on your site.
40
+ * Valid values range from 0.0 to 1.0.
41
+ * The default priority of a page is 0.5.
42
+ *
43
+ * Note: as of 2024, Google ignores this value.
44
+ * See also https://github.com/facebook/docusaurus/issues/2604
45
+ */
46
+ priority?: number | null;
47
+ };
package/lib/types.js ADDED
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) Facebook, Inc. and its affiliates.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.ChangeFreqList = exports.LastModOptionList = void 0;
10
+ exports.LastModOptionList = ['date', 'datetime'];
11
+ // types are according to the sitemap spec:
12
+ // see also https://www.sitemaps.org/protocol.html
13
+ exports.ChangeFreqList = [
14
+ 'hourly',
15
+ 'daily',
16
+ 'weekly',
17
+ 'monthly',
18
+ 'yearly',
19
+ 'always',
20
+ 'never',
21
+ ];
package/lib/xml.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import type { LastModOption, SitemapItem } from './types';
8
+ export declare function sitemapItemsToXmlString(items: SitemapItem[], options: {
9
+ lastmod: LastModOption | null;
10
+ }): Promise<string>;
package/lib/xml.js ADDED
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) Facebook, Inc. and its affiliates.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.sitemapItemsToXmlString = void 0;
10
+ const sitemap_1 = require("sitemap");
11
+ async function sitemapItemsToXmlString(items, options) {
12
+ if (items.length === 0) {
13
+ // Note: technically we could, but there is a bug in the lib code
14
+ // and the code below would never resolve, so it's better to fail fast
15
+ throw new Error("Can't generate a sitemap with no items");
16
+ }
17
+ // TODO remove sitemap lib dependency?
18
+ // https://github.com/ekalinin/sitemap.js
19
+ // it looks like an outdated confusion super old lib
20
+ // we might as well achieve the same result with a pure xml lib
21
+ const sitemapStream = new sitemap_1.SitemapStream({
22
+ // WTF is this lib reformatting the string YYYY-MM-DD to datetime...
23
+ lastmodDateOnly: options?.lastmod === 'date',
24
+ });
25
+ items.forEach((item) => sitemapStream.write(item));
26
+ sitemapStream.end();
27
+ const buffer = await (0, sitemap_1.streamToPromise)(sitemapStream);
28
+ return buffer.toString();
29
+ }
30
+ exports.sitemapItemsToXmlString = sitemapItemsToXmlString;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docusaurus/plugin-sitemap",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "description": "Simple sitemap generation plugin for Docusaurus.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -18,16 +18,19 @@
18
18
  },
19
19
  "license": "MIT",
20
20
  "dependencies": {
21
- "@docusaurus/core": "3.1.1",
22
- "@docusaurus/logger": "3.1.1",
23
- "@docusaurus/types": "3.1.1",
24
- "@docusaurus/utils": "3.1.1",
25
- "@docusaurus/utils-common": "3.1.1",
26
- "@docusaurus/utils-validation": "3.1.1",
21
+ "@docusaurus/core": "3.2.0",
22
+ "@docusaurus/logger": "3.2.0",
23
+ "@docusaurus/types": "3.2.0",
24
+ "@docusaurus/utils": "3.2.0",
25
+ "@docusaurus/utils-common": "3.2.0",
26
+ "@docusaurus/utils-validation": "3.2.0",
27
27
  "fs-extra": "^11.1.1",
28
28
  "sitemap": "^7.1.1",
29
29
  "tslib": "^2.6.0"
30
30
  },
31
+ "devDependencies": {
32
+ "@total-typescript/shoehorn": "^0.1.2"
33
+ },
31
34
  "peerDependencies": {
32
35
  "react": "^18.0.0",
33
36
  "react-dom": "^18.0.0"
@@ -35,5 +38,5 @@
35
38
  "engines": {
36
39
  "node": ">=18.0"
37
40
  },
38
- "gitHead": "8017f6a6776ba1bd7065e630a52fe2c2654e2f1b"
41
+ "gitHead": "5af143651b26b39761361acd96e9c5be7ba0cb25"
39
42
  }
@@ -6,13 +6,23 @@
6
6
  */
7
7
 
8
8
  import type {ReactElement} from 'react';
9
- import {SitemapStream, streamToPromise} from 'sitemap';
10
- import {applyTrailingSlash} from '@docusaurus/utils-common';
11
- import {createMatcher} from '@docusaurus/utils';
12
- import type {DocusaurusConfig} from '@docusaurus/types';
9
+ import {createMatcher, flattenRoutes} from '@docusaurus/utils';
10
+ import {sitemapItemsToXmlString} from './xml';
11
+ import {createSitemapItem} from './createSitemapItem';
12
+ import type {SitemapItem} from './types';
13
+ import type {DocusaurusConfig, RouteConfig} from '@docusaurus/types';
13
14
  import type {HelmetServerState} from 'react-helmet-async';
14
15
  import type {PluginOptions} from './options';
15
16
 
17
+ type CreateSitemapParams = {
18
+ siteConfig: DocusaurusConfig;
19
+ routes: RouteConfig[];
20
+ head: {[location: string]: HelmetServerState};
21
+ options: PluginOptions;
22
+ };
23
+
24
+ // Maybe we want to add a routeConfig.metadata.noIndex instead?
25
+ // But using Helmet is more reliable for third-party plugins...
16
26
  function isNoIndexMetaRoute({
17
27
  head,
18
28
  route,
@@ -47,50 +57,51 @@ function isNoIndexMetaRoute({
47
57
  );
48
58
  }
49
59
 
50
- export default async function createSitemap(
51
- siteConfig: DocusaurusConfig,
52
- routesPaths: string[],
53
- head: {[location: string]: HelmetServerState},
54
- options: PluginOptions,
55
- ): Promise<string | null> {
56
- const {url: hostname} = siteConfig;
57
- if (!hostname) {
58
- throw new Error('URL in docusaurus.config.js cannot be empty/undefined.');
59
- }
60
- const {changefreq, priority, ignorePatterns} = options;
60
+ // Not all routes should appear in the sitemap, and we should filter:
61
+ // - parent routes, used for layouts
62
+ // - routes matching options.ignorePatterns
63
+ // - routes with no index metadata
64
+ function getSitemapRoutes({routes, head, options}: CreateSitemapParams) {
65
+ const {ignorePatterns} = options;
61
66
 
62
67
  const ignoreMatcher = createMatcher(ignorePatterns);
63
68
 
64
- function isRouteExcluded(route: string) {
69
+ function isRouteExcluded(route: RouteConfig) {
65
70
  return (
66
- route.endsWith('404.html') ||
67
- ignoreMatcher(route) ||
68
- isNoIndexMetaRoute({head, route})
71
+ ignoreMatcher(route.path) || isNoIndexMetaRoute({head, route: route.path})
69
72
  );
70
73
  }
71
74
 
72
- const includedRoutes = routesPaths.filter((route) => !isRouteExcluded(route));
75
+ return flattenRoutes(routes).filter((route) => !isRouteExcluded(route));
76
+ }
73
77
 
74
- if (includedRoutes.length === 0) {
75
- return null;
78
+ async function createSitemapItems(
79
+ params: CreateSitemapParams,
80
+ ): Promise<SitemapItem[]> {
81
+ const sitemapRoutes = getSitemapRoutes(params);
82
+ if (sitemapRoutes.length === 0) {
83
+ return [];
76
84
  }
77
-
78
- const sitemapStream = new SitemapStream({hostname});
79
-
80
- includedRoutes.forEach((routePath) =>
81
- sitemapStream.write({
82
- url: applyTrailingSlash(routePath, {
83
- trailingSlash: siteConfig.trailingSlash,
84
- baseUrl: siteConfig.baseUrl,
85
+ return Promise.all(
86
+ sitemapRoutes.map((route) =>
87
+ createSitemapItem({
88
+ route,
89
+ siteConfig: params.siteConfig,
90
+ options: params.options,
85
91
  }),
86
- changefreq,
87
- priority,
88
- }),
92
+ ),
89
93
  );
94
+ }
90
95
 
91
- sitemapStream.end();
92
-
93
- const generatedSitemap = (await streamToPromise(sitemapStream)).toString();
94
-
95
- return generatedSitemap;
96
+ export default async function createSitemap(
97
+ params: CreateSitemapParams,
98
+ ): Promise<string | null> {
99
+ const items = await createSitemapItems(params);
100
+ if (items.length === 0) {
101
+ return null;
102
+ }
103
+ const xmlString = await sitemapItemsToXmlString(items, {
104
+ lastmod: params.options.lastmod,
105
+ });
106
+ return xmlString;
96
107
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import {applyTrailingSlash} from '@docusaurus/utils-common';
9
+ import {getLastUpdate, normalizeUrl} from '@docusaurus/utils';
10
+ import type {LastModOption, SitemapItem} from './types';
11
+ import type {DocusaurusConfig, RouteConfig} from '@docusaurus/types';
12
+ import type {PluginOptions} from './options';
13
+
14
+ async function getRouteLastUpdatedAt(
15
+ route: RouteConfig,
16
+ ): Promise<number | undefined> {
17
+ if (route.metadata?.lastUpdatedAt) {
18
+ return route.metadata?.lastUpdatedAt;
19
+ }
20
+ if (route.metadata?.sourceFilePath) {
21
+ const lastUpdate = await getLastUpdate(route.metadata?.sourceFilePath);
22
+ return lastUpdate?.lastUpdatedAt;
23
+ }
24
+
25
+ return undefined;
26
+ }
27
+
28
+ type LastModFormatter = (timestamp: number) => string;
29
+
30
+ const LastmodFormatters: Record<LastModOption, LastModFormatter> = {
31
+ date: (timestamp) => new Date(timestamp).toISOString().split('T')[0]!,
32
+ datetime: (timestamp) => new Date(timestamp).toISOString(),
33
+ };
34
+
35
+ function formatLastmod(timestamp: number, lastmodOption: LastModOption) {
36
+ const format = LastmodFormatters[lastmodOption];
37
+ return format(timestamp);
38
+ }
39
+
40
+ async function getRouteLastmod({
41
+ route,
42
+ lastmod,
43
+ }: {
44
+ route: RouteConfig;
45
+ lastmod: LastModOption | null;
46
+ }): Promise<string | null> {
47
+ if (lastmod === null) {
48
+ return null;
49
+ }
50
+ const lastUpdatedAt = (await getRouteLastUpdatedAt(route)) ?? null;
51
+ return lastUpdatedAt ? formatLastmod(lastUpdatedAt, lastmod) : null;
52
+ }
53
+
54
+ export async function createSitemapItem({
55
+ route,
56
+ siteConfig,
57
+ options,
58
+ }: {
59
+ route: RouteConfig;
60
+ siteConfig: DocusaurusConfig;
61
+ options: PluginOptions;
62
+ }): Promise<SitemapItem> {
63
+ const {changefreq, priority, lastmod} = options;
64
+ return {
65
+ url: normalizeUrl([
66
+ siteConfig.url,
67
+ applyTrailingSlash(route.path, {
68
+ trailingSlash: siteConfig.trailingSlash,
69
+ baseUrl: siteConfig.baseUrl,
70
+ }),
71
+ ]),
72
+ changefreq,
73
+ priority,
74
+ lastmod: await getRouteLastmod({route, lastmod}),
75
+ };
76
+ }
package/src/index.ts CHANGED
@@ -19,17 +19,17 @@ export default function pluginSitemap(
19
19
  return {
20
20
  name: 'docusaurus-plugin-sitemap',
21
21
 
22
- async postBuild({siteConfig, routesPaths, outDir, head}) {
22
+ async postBuild({siteConfig, routes, outDir, head}) {
23
23
  if (siteConfig.noIndex) {
24
24
  return;
25
25
  }
26
26
  // Generate sitemap.
27
- const generatedSitemap = await createSitemap(
27
+ const generatedSitemap = await createSitemap({
28
28
  siteConfig,
29
- routesPaths,
29
+ routes,
30
30
  head,
31
31
  options,
32
- );
32
+ });
33
33
  if (!generatedSitemap) {
34
34
  return;
35
35
  }
package/src/options.ts CHANGED
@@ -6,33 +6,60 @@
6
6
  */
7
7
 
8
8
  import {Joi} from '@docusaurus/utils-validation';
9
- import {EnumChangefreq} from 'sitemap';
9
+ import {ChangeFreqList, LastModOptionList} from './types';
10
10
  import type {OptionValidationContext} from '@docusaurus/types';
11
+ import type {ChangeFreq, LastModOption} from './types';
11
12
 
12
13
  export type PluginOptions = {
13
- /** @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions */
14
- changefreq: EnumChangefreq;
15
- /** @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions */
16
- priority: number;
14
+ /**
15
+ * The path to the created sitemap file, relative to the output directory.
16
+ * Useful if you have two plugin instances outputting two files.
17
+ */
18
+ filename: string;
19
+
17
20
  /**
18
21
  * A list of glob patterns; matching route paths will be filtered from the
19
22
  * sitemap. Note that you may need to include the base URL in here.
20
23
  */
21
24
  ignorePatterns: string[];
25
+
22
26
  /**
23
- * The path to the created sitemap file, relative to the output directory.
24
- * Useful if you have two plugin instances outputting two files.
27
+ * Defines the format of the "lastmod" sitemap item entry, between:
28
+ * - null: do not compute/add a "lastmod" sitemap entry
29
+ * - "date": add a "lastmod" sitemap entry without time (YYYY-MM-DD)
30
+ * - "datetime": add a "lastmod" sitemap entry with time (ISO 8601 datetime)
31
+ * @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
32
+ * @see https://www.w3.org/TR/NOTE-datetime
25
33
  */
26
- filename: string;
34
+ lastmod: LastModOption | null;
35
+
36
+ /**
37
+ * TODO Docusaurus v4 breaking change: remove useless option
38
+ * @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
39
+ */
40
+ changefreq: ChangeFreq | null;
41
+
42
+ /**
43
+ * TODO Docusaurus v4 breaking change: remove useless option
44
+ * @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
45
+ */
46
+ priority: number | null;
27
47
  };
28
48
 
29
49
  export type Options = Partial<PluginOptions>;
30
50
 
31
51
  export const DEFAULT_OPTIONS: PluginOptions = {
32
- changefreq: EnumChangefreq.WEEKLY,
33
- priority: 0.5,
34
- ignorePatterns: [],
35
52
  filename: 'sitemap.xml',
53
+ ignorePatterns: [],
54
+
55
+ // TODO Docusaurus v4 breaking change
56
+ // change default to "date" if no bug or perf issue reported
57
+ lastmod: null,
58
+
59
+ // TODO Docusaurus v4 breaking change
60
+ // those options are useless and should be removed
61
+ changefreq: 'weekly',
62
+ priority: 0.5,
36
63
  };
37
64
 
38
65
  const PluginOptionSchema = Joi.object<PluginOptions>({
@@ -41,10 +68,28 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
41
68
  'any.unknown':
42
69
  'Option `cacheTime` in sitemap config is deprecated. Please remove it.',
43
70
  }),
71
+
72
+ // TODO remove for Docusaurus v4 breaking changes?
73
+ // This is not even used by Google crawlers
74
+ // See also https://github.com/facebook/docusaurus/issues/2604
44
75
  changefreq: Joi.string()
45
- .valid(...Object.values(EnumChangefreq))
76
+ .valid(null, ...ChangeFreqList)
46
77
  .default(DEFAULT_OPTIONS.changefreq),
47
- priority: Joi.number().min(0).max(1).default(DEFAULT_OPTIONS.priority),
78
+
79
+ // TODO remove for Docusaurus v4 breaking changes?
80
+ // This is not even used by Google crawlers
81
+ // The priority is "relative", and using the same priority for all routes
82
+ // does not make sense according to the spec
83
+ // See also https://github.com/facebook/docusaurus/issues/2604
84
+ // See also https://www.sitemaps.org/protocol.html
85
+ priority: Joi.alternatives()
86
+ .try(Joi.valid(null), Joi.number().min(0).max(1))
87
+ .default(DEFAULT_OPTIONS.priority),
88
+
89
+ lastmod: Joi.string()
90
+ .valid(null, ...LastModOptionList)
91
+ .default(DEFAULT_OPTIONS.lastmod),
92
+
48
93
  ignorePatterns: Joi.array()
49
94
  .items(Joi.string())
50
95
  .default(DEFAULT_OPTIONS.ignorePatterns),
package/src/types.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ export const LastModOptionList = ['date', 'datetime'] as const;
9
+
10
+ export type LastModOption = (typeof LastModOptionList)[number];
11
+
12
+ // types are according to the sitemap spec:
13
+ // see also https://www.sitemaps.org/protocol.html
14
+
15
+ export const ChangeFreqList = [
16
+ 'hourly',
17
+ 'daily',
18
+ 'weekly',
19
+ 'monthly',
20
+ 'yearly',
21
+ 'always',
22
+ 'never',
23
+ ] as const;
24
+
25
+ export type ChangeFreq = (typeof ChangeFreqList)[number];
26
+
27
+ // We re-recreate our own type because the "sitemap" lib types are not good
28
+ export type SitemapItem = {
29
+ /**
30
+ * URL of the page.
31
+ * This URL must begin with the protocol (such as http).
32
+ * It should eventually end with a trailing slash.
33
+ * It should be less than 2,048 characters.
34
+ */
35
+ url: string;
36
+
37
+ /**
38
+ * ISO 8601 date string.
39
+ * See also https://www.w3.org/TR/NOTE-datetime
40
+ *
41
+ * It is recommended to use one of:
42
+ * - date.toISOString()
43
+ * - YYYY-MM-DD
44
+ *
45
+ * Note: as of 2024, Google uses this value for crawling priority.
46
+ * See also https://github.com/facebook/docusaurus/issues/2604
47
+ */
48
+ lastmod?: string | null;
49
+
50
+ /**
51
+ * One of the specified enum values
52
+ *
53
+ * Note: as of 2024, Google ignores this value.
54
+ * See also https://github.com/facebook/docusaurus/issues/2604
55
+ */
56
+ changefreq?: ChangeFreq | null;
57
+
58
+ /**
59
+ * The priority of this URL relative to other URLs on your site.
60
+ * Valid values range from 0.0 to 1.0.
61
+ * The default priority of a page is 0.5.
62
+ *
63
+ * Note: as of 2024, Google ignores this value.
64
+ * See also https://github.com/facebook/docusaurus/issues/2604
65
+ */
66
+ priority?: number | null;
67
+ };
package/src/xml.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import {SitemapStream, streamToPromise} from 'sitemap';
9
+ import type {LastModOption, SitemapItem} from './types';
10
+
11
+ export async function sitemapItemsToXmlString(
12
+ items: SitemapItem[],
13
+ options: {lastmod: LastModOption | null},
14
+ ): Promise<string> {
15
+ if (items.length === 0) {
16
+ // Note: technically we could, but there is a bug in the lib code
17
+ // and the code below would never resolve, so it's better to fail fast
18
+ throw new Error("Can't generate a sitemap with no items");
19
+ }
20
+
21
+ // TODO remove sitemap lib dependency?
22
+ // https://github.com/ekalinin/sitemap.js
23
+ // it looks like an outdated confusion super old lib
24
+ // we might as well achieve the same result with a pure xml lib
25
+ const sitemapStream = new SitemapStream({
26
+ // WTF is this lib reformatting the string YYYY-MM-DD to datetime...
27
+ lastmodDateOnly: options?.lastmod === 'date',
28
+ });
29
+
30
+ items.forEach((item) => sitemapStream.write(item));
31
+ sitemapStream.end();
32
+
33
+ const buffer = await streamToPromise(sitemapStream);
34
+ return buffer.toString();
35
+ }