feat(sitemap): add support for "lastmod" (#9954)

This commit is contained in:
Sébastien Lorber 2024-03-20 11:47:44 +01:00 committed by GitHub
parent 465cf4d82c
commit 9017fb9b1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1449 additions and 359 deletions

6
.eslintrc.js vendored
View file

@ -85,6 +85,7 @@ module.exports = {
ignorePattern: '(eslint-disable|@)', ignorePattern: '(eslint-disable|@)',
}, },
], ],
'arrow-body-style': OFF,
'no-await-in-loop': OFF, 'no-await-in-loop': OFF,
'no-case-declarations': WARNING, 'no-case-declarations': WARNING,
'no-console': OFF, 'no-console': OFF,
@ -347,10 +348,7 @@ module.exports = {
ERROR, ERROR,
{'ts-expect-error': 'allow-with-description'}, {'ts-expect-error': 'allow-with-description'},
], ],
'@typescript-eslint/consistent-indexed-object-style': [ '@typescript-eslint/consistent-indexed-object-style': OFF,
WARNING,
'index-signature',
],
'@typescript-eslint/consistent-type-imports': [ '@typescript-eslint/consistent-type-imports': [
WARNING, WARNING,
{disallowTypeAnnotations: false}, {disallowTypeAnnotations: false},

View file

@ -11,8 +11,7 @@ import {normalizePluginOptions} from '@docusaurus/utils-validation';
import { import {
posixPath, posixPath,
getFileCommitDate, getFileCommitDate,
GIT_FALLBACK_LAST_UPDATE_DATE, LAST_UPDATE_FALLBACK,
GIT_FALLBACK_LAST_UPDATE_AUTHOR,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import pluginContentBlog from '../index'; import pluginContentBlog from '../index';
import {validateOptions} from '../options'; import {validateOptions} from '../options';
@ -554,14 +553,14 @@ describe('last update', () => {
expect(blogPosts[0]?.metadata.lastUpdatedBy).toBe('seb'); expect(blogPosts[0]?.metadata.lastUpdatedBy).toBe('seb');
expect(blogPosts[0]?.metadata.lastUpdatedAt).toBe( expect(blogPosts[0]?.metadata.lastUpdatedAt).toBe(
GIT_FALLBACK_LAST_UPDATE_DATE, LAST_UPDATE_FALLBACK.lastUpdatedAt,
); );
expect(blogPosts[1]?.metadata.lastUpdatedBy).toBe( expect(blogPosts[1]?.metadata.lastUpdatedBy).toBe(
GIT_FALLBACK_LAST_UPDATE_AUTHOR, LAST_UPDATE_FALLBACK.lastUpdatedBy,
); );
expect(blogPosts[1]?.metadata.lastUpdatedAt).toBe( expect(blogPosts[1]?.metadata.lastUpdatedAt).toBe(
GIT_FALLBACK_LAST_UPDATE_DATE, LAST_UPDATE_FALLBACK.lastUpdatedAt,
); );
expect(blogPosts[2]?.metadata.lastUpdatedBy).toBe('seb'); expect(blogPosts[2]?.metadata.lastUpdatedBy).toBe('seb');
@ -570,7 +569,7 @@ describe('last update', () => {
); );
expect(blogPosts[3]?.metadata.lastUpdatedBy).toBe( expect(blogPosts[3]?.metadata.lastUpdatedBy).toBe(
GIT_FALLBACK_LAST_UPDATE_AUTHOR, LAST_UPDATE_FALLBACK.lastUpdatedBy,
); );
expect(blogPosts[3]?.metadata.lastUpdatedAt).toBe( expect(blogPosts[3]?.metadata.lastUpdatedAt).toBe(
lastUpdateFor('2021-01-01'), lastUpdateFor('2021-01-01'),
@ -591,13 +590,13 @@ describe('last update', () => {
expect(blogPosts[0]?.metadata.title).toBe('Author'); expect(blogPosts[0]?.metadata.title).toBe('Author');
expect(blogPosts[0]?.metadata.lastUpdatedBy).toBeUndefined(); expect(blogPosts[0]?.metadata.lastUpdatedBy).toBeUndefined();
expect(blogPosts[0]?.metadata.lastUpdatedAt).toBe( expect(blogPosts[0]?.metadata.lastUpdatedAt).toBe(
GIT_FALLBACK_LAST_UPDATE_DATE, LAST_UPDATE_FALLBACK.lastUpdatedAt,
); );
expect(blogPosts[1]?.metadata.title).toBe('Nothing'); expect(blogPosts[1]?.metadata.title).toBe('Nothing');
expect(blogPosts[1]?.metadata.lastUpdatedBy).toBeUndefined(); expect(blogPosts[1]?.metadata.lastUpdatedBy).toBeUndefined();
expect(blogPosts[1]?.metadata.lastUpdatedAt).toBe( expect(blogPosts[1]?.metadata.lastUpdatedAt).toBe(
GIT_FALLBACK_LAST_UPDATE_DATE, LAST_UPDATE_FALLBACK.lastUpdatedAt,
); );
expect(blogPosts[2]?.metadata.title).toBe('Both'); expect(blogPosts[2]?.metadata.title).toBe('Both');
@ -628,7 +627,7 @@ describe('last update', () => {
expect(blogPosts[0]?.metadata.lastUpdatedAt).toBeUndefined(); expect(blogPosts[0]?.metadata.lastUpdatedAt).toBeUndefined();
expect(blogPosts[1]?.metadata.lastUpdatedBy).toBe( expect(blogPosts[1]?.metadata.lastUpdatedBy).toBe(
GIT_FALLBACK_LAST_UPDATE_AUTHOR, LAST_UPDATE_FALLBACK.lastUpdatedBy,
); );
expect(blogPosts[1]?.metadata.lastUpdatedAt).toBeUndefined(); expect(blogPosts[1]?.metadata.lastUpdatedAt).toBeUndefined();
@ -636,7 +635,7 @@ describe('last update', () => {
expect(blogPosts[2]?.metadata.lastUpdatedAt).toBeUndefined(); expect(blogPosts[2]?.metadata.lastUpdatedAt).toBeUndefined();
expect(blogPosts[3]?.metadata.lastUpdatedBy).toBe( expect(blogPosts[3]?.metadata.lastUpdatedBy).toBe(
GIT_FALLBACK_LAST_UPDATE_AUTHOR, LAST_UPDATE_FALLBACK.lastUpdatedBy,
); );
expect(blogPosts[3]?.metadata.lastUpdatedAt).toBeUndefined(); expect(blogPosts[3]?.metadata.lastUpdatedAt).toBeUndefined();
}); });

View file

@ -11,6 +11,7 @@ import {
normalizeUrl, normalizeUrl,
docuHash, docuHash,
aliasedSitePath, aliasedSitePath,
aliasedSitePathToRelativePath,
getPluginI18nPath, getPluginI18nPath,
posixPath, posixPath,
addTrailingPathSeparator, addTrailingPathSeparator,
@ -33,7 +34,12 @@ import {createBlogFeedFiles} from './feed';
import {toTagProp, toTagsProp} from './props'; import {toTagProp, toTagsProp} from './props';
import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types'; import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types';
import type {LoadContext, Plugin, HtmlTags} from '@docusaurus/types'; import type {
LoadContext,
Plugin,
HtmlTags,
RouteMetadata,
} from '@docusaurus/types';
import type { import type {
PluginOptions, PluginOptions,
BlogPostFrontMatter, BlogPostFrontMatter,
@ -273,6 +279,15 @@ export default async function pluginContentBlog(
JSON.stringify(blogMetadata, null, 2), JSON.stringify(blogMetadata, null, 2),
); );
function createBlogPostRouteMetadata(
blogPostMeta: BlogPostMetadata,
): RouteMetadata {
return {
sourceFilePath: aliasedSitePathToRelativePath(blogPostMeta.source),
lastUpdatedAt: blogPostMeta.lastUpdatedAt,
};
}
// Create routes for blog entries. // Create routes for blog entries.
await Promise.all( await Promise.all(
blogPosts.map(async (blogPost) => { blogPosts.map(async (blogPost) => {
@ -292,6 +307,7 @@ export default async function pluginContentBlog(
sidebar: aliasedSource(sidebarProp), sidebar: aliasedSource(sidebarProp),
content: metadata.source, content: metadata.source,
}, },
metadata: createBlogPostRouteMetadata(metadata),
context: { context: {
blogMetadata: aliasedSource(blogMetadataPath), blogMetadata: aliasedSource(blogMetadataPath),
}, },

View file

@ -1482,6 +1482,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/hello.md",
},
"modules": { "modules": {
"content": "@site/docs/hello.md", "content": "@site/docs/hello.md",
}, },
@ -1491,6 +1495,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/slugs/absoluteSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/slugs/absoluteSlug.md", "content": "@site/docs/slugs/absoluteSlug.md",
}, },
@ -1508,6 +1516,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/customLastUpdate.md",
},
"modules": { "modules": {
"content": "@site/docs/customLastUpdate.md", "content": "@site/docs/customLastUpdate.md",
}, },
@ -1516,6 +1528,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/doc with space.md",
},
"modules": { "modules": {
"content": "@site/docs/doc with space.md", "content": "@site/docs/doc with space.md",
}, },
@ -1524,6 +1540,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/doc-draft.md",
},
"modules": { "modules": {
"content": "@site/docs/doc-draft.md", "content": "@site/docs/doc-draft.md",
}, },
@ -1532,6 +1552,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/doc-unlisted.md",
},
"modules": { "modules": {
"content": "@site/docs/doc-unlisted.md", "content": "@site/docs/doc-unlisted.md",
}, },
@ -1541,6 +1565,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/foo/bar.md",
},
"modules": { "modules": {
"content": "@site/docs/foo/bar.md", "content": "@site/docs/foo/bar.md",
}, },
@ -1550,6 +1578,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/foo/baz.md",
},
"modules": { "modules": {
"content": "@site/docs/foo/baz.md", "content": "@site/docs/foo/baz.md",
}, },
@ -1559,6 +1591,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/headingAsTitle.md",
},
"modules": { "modules": {
"content": "@site/docs/headingAsTitle.md", "content": "@site/docs/headingAsTitle.md",
}, },
@ -1568,6 +1604,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/rootResolvedSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/rootResolvedSlug.md", "content": "@site/docs/rootResolvedSlug.md",
}, },
@ -1577,6 +1617,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/ipsum.md",
},
"modules": { "modules": {
"content": "@site/docs/ipsum.md", "content": "@site/docs/ipsum.md",
}, },
@ -1585,6 +1629,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/lastUpdateAuthorOnly.md",
},
"modules": { "modules": {
"content": "@site/docs/lastUpdateAuthorOnly.md", "content": "@site/docs/lastUpdateAuthorOnly.md",
}, },
@ -1593,6 +1641,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/lastUpdateDateOnly.md",
},
"modules": { "modules": {
"content": "@site/docs/lastUpdateDateOnly.md", "content": "@site/docs/lastUpdateDateOnly.md",
}, },
@ -1601,6 +1653,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/lorem.md",
},
"modules": { "modules": {
"content": "@site/docs/lorem.md", "content": "@site/docs/lorem.md",
}, },
@ -1609,6 +1665,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/rootAbsoluteSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/rootAbsoluteSlug.md", "content": "@site/docs/rootAbsoluteSlug.md",
}, },
@ -1618,6 +1678,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/rootRelativeSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/rootRelativeSlug.md", "content": "@site/docs/rootRelativeSlug.md",
}, },
@ -1627,6 +1691,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/rootTryToEscapeSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/rootTryToEscapeSlug.md", "content": "@site/docs/rootTryToEscapeSlug.md",
}, },
@ -1636,6 +1704,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/slugs/resolvedSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/slugs/resolvedSlug.md", "content": "@site/docs/slugs/resolvedSlug.md",
}, },
@ -1644,6 +1716,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/slugs/relativeSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/slugs/relativeSlug.md", "content": "@site/docs/slugs/relativeSlug.md",
}, },
@ -1652,6 +1728,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/slugs/tryToEscapeSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/slugs/tryToEscapeSlug.md", "content": "@site/docs/slugs/tryToEscapeSlug.md",
}, },
@ -1660,6 +1740,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/unlisted-category/index.md",
},
"modules": { "modules": {
"content": "@site/docs/unlisted-category/index.md", "content": "@site/docs/unlisted-category/index.md",
}, },
@ -1669,6 +1753,10 @@ exports[`simple website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/unlisted-category/unlisted-category-doc.md",
},
"modules": { "modules": {
"content": "@site/docs/unlisted-category/unlisted-category-doc.md", "content": "@site/docs/unlisted-category/unlisted-category-doc.md",
}, },
@ -2940,6 +3028,10 @@ exports[`versioned website (community) content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "i18n/en/docusaurus-plugin-content-docs-community/current/team.md",
},
"modules": { "modules": {
"content": "@site/i18n/en/docusaurus-plugin-content-docs-community/current/team.md", "content": "@site/i18n/en/docusaurus-plugin-content-docs-community/current/team.md",
}, },
@ -2967,6 +3059,10 @@ exports[`versioned website (community) content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "community_versioned_docs/version-1.0.0/team.md",
},
"modules": { "modules": {
"content": "@site/community_versioned_docs/version-1.0.0/team.md", "content": "@site/community_versioned_docs/version-1.0.0/team.md",
}, },
@ -4174,6 +4270,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md",
},
"modules": { "modules": {
"content": "@site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md", "content": "@site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md",
}, },
@ -4183,6 +4283,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-1.0.0/foo/bar.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-1.0.0/foo/bar.md", "content": "@site/versioned_docs/version-1.0.0/foo/bar.md",
}, },
@ -4192,6 +4296,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-1.0.0/foo/baz.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-1.0.0/foo/baz.md", "content": "@site/versioned_docs/version-1.0.0/foo/baz.md",
}, },
@ -4251,6 +4359,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/hello.md",
},
"modules": { "modules": {
"content": "@site/docs/hello.md", "content": "@site/docs/hello.md",
}, },
@ -4260,6 +4372,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/slugs/absoluteSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/slugs/absoluteSlug.md", "content": "@site/docs/slugs/absoluteSlug.md",
}, },
@ -4268,6 +4384,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/foo/bar.md",
},
"modules": { "modules": {
"content": "@site/docs/foo/bar.md", "content": "@site/docs/foo/bar.md",
}, },
@ -4277,6 +4397,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/slugs/resolvedSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/slugs/resolvedSlug.md", "content": "@site/docs/slugs/resolvedSlug.md",
}, },
@ -4285,6 +4409,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/slugs/relativeSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/slugs/relativeSlug.md", "content": "@site/docs/slugs/relativeSlug.md",
}, },
@ -4293,6 +4421,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "docs/slugs/tryToEscapeSlug.md",
},
"modules": { "modules": {
"content": "@site/docs/slugs/tryToEscapeSlug.md", "content": "@site/docs/slugs/tryToEscapeSlug.md",
}, },
@ -4319,6 +4451,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-withSlugs/slugs/absoluteSlug.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-withSlugs/slugs/absoluteSlug.md", "content": "@site/versioned_docs/version-withSlugs/slugs/absoluteSlug.md",
}, },
@ -4327,6 +4463,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-withSlugs/rootResolvedSlug.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-withSlugs/rootResolvedSlug.md", "content": "@site/versioned_docs/version-withSlugs/rootResolvedSlug.md",
}, },
@ -4335,6 +4475,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-withSlugs/rootAbsoluteSlug.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-withSlugs/rootAbsoluteSlug.md", "content": "@site/versioned_docs/version-withSlugs/rootAbsoluteSlug.md",
}, },
@ -4344,6 +4488,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-withSlugs/rootRelativeSlug.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-withSlugs/rootRelativeSlug.md", "content": "@site/versioned_docs/version-withSlugs/rootRelativeSlug.md",
}, },
@ -4352,6 +4500,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-withSlugs/rootTryToEscapeSlug.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-withSlugs/rootTryToEscapeSlug.md", "content": "@site/versioned_docs/version-withSlugs/rootTryToEscapeSlug.md",
}, },
@ -4360,6 +4512,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-withSlugs/slugs/resolvedSlug.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-withSlugs/slugs/resolvedSlug.md", "content": "@site/versioned_docs/version-withSlugs/slugs/resolvedSlug.md",
}, },
@ -4368,6 +4524,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-withSlugs/slugs/relativeSlug.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-withSlugs/slugs/relativeSlug.md", "content": "@site/versioned_docs/version-withSlugs/slugs/relativeSlug.md",
}, },
@ -4376,6 +4536,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-withSlugs/slugs/tryToEscapeSlug.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-withSlugs/slugs/tryToEscapeSlug.md", "content": "@site/versioned_docs/version-withSlugs/slugs/tryToEscapeSlug.md",
}, },
@ -4402,6 +4566,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-1.0.1/hello.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-1.0.1/hello.md", "content": "@site/versioned_docs/version-1.0.1/hello.md",
}, },
@ -4411,6 +4579,10 @@ exports[`versioned website content: route config 1`] = `
{ {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
"metadata": {
"lastUpdatedAt": undefined,
"sourceFilePath": "versioned_docs/version-1.0.1/foo/bar.md",
},
"modules": { "modules": {
"content": "@site/versioned_docs/version-1.0.1/foo/bar.md", "content": "@site/versioned_docs/version-1.0.1/foo/bar.md",
}, },

View file

@ -12,7 +12,7 @@ import {
createSlugger, createSlugger,
posixPath, posixPath,
DEFAULT_PLUGIN_ID, DEFAULT_PLUGIN_ID,
GIT_FALLBACK_LAST_UPDATE_DATE, LAST_UPDATE_FALLBACK,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import {createSidebarsUtils} from '../sidebars/utils'; import {createSidebarsUtils} from '../sidebars/utils';
import { import {
@ -479,8 +479,8 @@ describe('simple site', () => {
custom_edit_url: 'https://github.com/customUrl/docs/lorem.md', custom_edit_url: 'https://github.com/customUrl/docs/lorem.md',
unrelated_front_matter: "won't be part of metadata", unrelated_front_matter: "won't be part of metadata",
}, },
lastUpdatedAt: GIT_FALLBACK_LAST_UPDATE_DATE, lastUpdatedAt: LAST_UPDATE_FALLBACK.lastUpdatedAt,
lastUpdatedBy: 'Author', lastUpdatedBy: LAST_UPDATE_FALLBACK.lastUpdatedBy,
tags: [], tags: [],
unlisted: false, unlisted: false,
}); });
@ -614,7 +614,7 @@ describe('simple site', () => {
}, },
title: 'Last Update Author Only', title: 'Last Update Author Only',
}, },
lastUpdatedAt: GIT_FALLBACK_LAST_UPDATE_DATE, lastUpdatedAt: LAST_UPDATE_FALLBACK.lastUpdatedAt,
lastUpdatedBy: 'Custom Author (processed by parseFrontMatter)', lastUpdatedBy: 'Custom Author (processed by parseFrontMatter)',
sidebarPosition: undefined, sidebarPosition: undefined,
tags: [], tags: [],

View file

@ -17,6 +17,7 @@ declare module '@docusaurus/plugin-content-docs' {
TagModule, TagModule,
Tag, Tag,
FrontMatterLastUpdate, FrontMatterLastUpdate,
LastUpdateData,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import type {Plugin, LoadContext} from '@docusaurus/types'; import type {Plugin, LoadContext} from '@docusaurus/types';
import type {Overwrite, Required} from 'utility-types'; import type {Overwrite, Required} from 'utility-types';
@ -397,13 +398,6 @@ declare module '@docusaurus/plugin-content-docs' {
last_update?: FrontMatterLastUpdate; last_update?: FrontMatterLastUpdate;
}; };
export type LastUpdateData = {
/** A timestamp in **seconds**, directly acquired from `git log`. */
lastUpdatedAt?: number;
/** The author's name directly acquired from `git log`. */
lastUpdatedBy?: string;
};
export type DocMetadataBase = LastUpdateData & { export type DocMetadataBase = LastUpdateData & {
/** /**
* The document id. * The document id.

View file

@ -7,21 +7,38 @@
import _ from 'lodash'; import _ from 'lodash';
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
import {docuHash, createSlugger, normalizeUrl} from '@docusaurus/utils'; import {
docuHash,
createSlugger,
normalizeUrl,
aliasedSitePathToRelativePath,
} from '@docusaurus/utils';
import { import {
toTagDocListProp, toTagDocListProp,
toTagsListTagsProp, toTagsListTagsProp,
toVersionMetadataProp, toVersionMetadataProp,
} from './props'; } from './props';
import {getVersionTags} from './tags'; import {getVersionTags} from './tags';
import type {PluginContentLoadedActions, RouteConfig} from '@docusaurus/types'; import type {
PluginContentLoadedActions,
RouteConfig,
RouteMetadata,
} from '@docusaurus/types';
import type {FullVersion, VersionTag} from './types'; import type {FullVersion, VersionTag} from './types';
import type { import type {
CategoryGeneratedIndexMetadata, CategoryGeneratedIndexMetadata,
DocMetadata,
PluginOptions, PluginOptions,
PropTagsListPage, PropTagsListPage,
} from '@docusaurus/plugin-content-docs'; } from '@docusaurus/plugin-content-docs';
function createDocRouteMetadata(docMeta: DocMetadata): RouteMetadata {
return {
sourceFilePath: aliasedSitePathToRelativePath(docMeta.source),
lastUpdatedAt: docMeta.lastUpdatedAt,
};
}
async function buildVersionCategoryGeneratedIndexRoutes({ async function buildVersionCategoryGeneratedIndexRoutes({
version, version,
actions, actions,
@ -68,26 +85,27 @@ async function buildVersionDocRoutes({
options, options,
}: BuildVersionRoutesParam): Promise<RouteConfig[]> { }: BuildVersionRoutesParam): Promise<RouteConfig[]> {
return Promise.all( return Promise.all(
version.docs.map(async (metadataItem) => { version.docs.map(async (doc) => {
await actions.createData( await actions.createData(
// Note that this created data path must be in sync with // Note that this created data path must be in sync with
// metadataPath provided to mdx-loader. // metadataPath provided to mdx-loader.
`${docuHash(metadataItem.source)}.json`, `${docuHash(doc.source)}.json`,
JSON.stringify(metadataItem, null, 2), JSON.stringify(doc, null, 2),
); );
const docRoute: RouteConfig = { const docRoute: RouteConfig = {
path: metadataItem.permalink, path: doc.permalink,
component: options.docItemComponent, component: options.docItemComponent,
exact: true, exact: true,
modules: { modules: {
content: metadataItem.source, content: doc.source,
}, },
metadata: createDocRouteMetadata(doc),
// Because the parent (DocRoot) comp need to access it easily // Because the parent (DocRoot) comp need to access it easily
// This permits to render the sidebar once without unmount/remount when // This permits to render the sidebar once without unmount/remount when
// navigating (and preserve sidebar state) // navigating (and preserve sidebar state)
...(metadataItem.sidebar && { ...(doc.sidebar && {
sidebar: metadataItem.sidebar, sidebar: doc.sidebar,
}), }),
}; };

View file

@ -11,6 +11,7 @@ import {
encodePath, encodePath,
fileToPath, fileToPath,
aliasedSitePath, aliasedSitePath,
aliasedSitePathToRelativePath,
docuHash, docuHash,
getPluginI18nPath, getPluginI18nPath,
getFolderContainingFile, getFolderContainingFile,
@ -24,8 +25,7 @@ import {
isDraft, isDraft,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import {validatePageFrontMatter} from './frontMatter'; import {validatePageFrontMatter} from './frontMatter';
import type {LoadContext, Plugin, RouteMetadata} from '@docusaurus/types';
import type {LoadContext, Plugin} from '@docusaurus/types';
import type {PagesContentPaths} from './types'; import type {PagesContentPaths} from './types';
import type { import type {
PluginOptions, PluginOptions,
@ -159,9 +159,20 @@ export default function pluginContentPages(
const {addRoute, createData} = actions; const {addRoute, createData} = actions;
function createPageRouteMetadata(metadata: Metadata): RouteMetadata {
return {
sourceFilePath: aliasedSitePathToRelativePath(metadata.source),
// TODO add support for last updated date in the page plugin
// at least for Markdown files
// lastUpdatedAt: metadata.lastUpdatedAt,
lastUpdatedAt: undefined,
};
}
await Promise.all( await Promise.all(
content.map(async (metadata) => { content.map(async (metadata) => {
const {permalink, source} = metadata; const {permalink, source} = metadata;
const routeMetadata = createPageRouteMetadata(metadata);
if (metadata.type === 'mdx') { if (metadata.type === 'mdx') {
await createData( await createData(
// Note that this created data path must be in sync with // Note that this created data path must be in sync with
@ -173,6 +184,7 @@ export default function pluginContentPages(
path: permalink, path: permalink,
component: options.mdxPageComponent, component: options.mdxPageComponent,
exact: true, exact: true,
metadata: routeMetadata,
modules: { modules: {
content: source, content: source,
}, },
@ -182,6 +194,7 @@ export default function pluginContentPages(
path: permalink, path: permalink,
component: source, component: source,
exact: true, exact: true,
metadata: routeMetadata,
modules: { modules: {
config: `@generated/docusaurus.config`, config: `@generated/docusaurus.config`,
}, },

View file

@ -28,6 +28,9 @@
"sitemap": "^7.1.1", "sitemap": "^7.1.1",
"tslib": "^2.6.0" "tslib": "^2.6.0"
}, },
"devDependencies": {
"@total-typescript/shoehorn": "^0.1.2"
},
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0" "react-dom": "^18.0.0"

View file

@ -6,95 +6,91 @@
*/ */
import React from 'react'; import React from 'react';
import {EnumChangefreq} from 'sitemap'; import {fromPartial} from '@total-typescript/shoehorn';
import createSitemap from '../createSitemap'; import createSitemap from '../createSitemap';
import type {PluginOptions} from '../options'; import type {PluginOptions} from '../options';
import type {DocusaurusConfig} from '@docusaurus/types'; import type {DocusaurusConfig, RouteConfig} from '@docusaurus/types';
const siteConfig: DocusaurusConfig = fromPartial({
url: 'https://example.com',
});
const options: PluginOptions = {
changefreq: 'daily',
priority: 0.7,
ignorePatterns: [],
filename: 'sitemap.xml',
lastmod: 'datetime',
};
const route = (routePath: string, routePaths?: string[]): RouteConfig => {
return fromPartial({
path: routePath,
routes: routePaths?.map((p) => route(p)),
});
};
const routes = (routePaths: string[]): RouteConfig[] => {
return routePaths.map((p) => route(p));
};
describe('createSitemap', () => { describe('createSitemap', () => {
it('simple site', async () => { it('simple site', async () => {
const sitemap = await createSitemap( const sitemap = await createSitemap({
{ siteConfig,
url: 'https://example.com', routes: routes(['/', '/test']),
} as DocusaurusConfig, head: {},
['/', '/test'], options,
{}, });
{
changefreq: EnumChangefreq.DAILY,
priority: 0.7,
ignorePatterns: [],
filename: 'sitemap.xml',
},
);
expect(sitemap).toContain( expect(sitemap).toContain(
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">`, `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">`,
); );
}); });
it('empty site', () => it('site with no routes', async () => {
expect(async () => { const sitemap = await createSitemap({
// @ts-expect-error: test siteConfig,
await createSitemap({}, [], {}, {} as PluginOptions); routes: routes([]),
}).rejects.toThrow( head: {},
'URL in docusaurus.config.js cannot be empty/undefined.', options,
)); });
expect(sitemap).toBeNull();
it('exclusion of 404 page', async () => {
const sitemap = await createSitemap(
{
url: 'https://example.com',
} as DocusaurusConfig,
['/', '/404.html', '/my-page'],
{},
{
changefreq: EnumChangefreq.DAILY,
priority: 0.7,
ignorePatterns: [],
filename: 'sitemap.xml',
},
);
expect(sitemap).not.toContain('404');
}); });
it('excludes patterns configured to be ignored', async () => { it('excludes patterns configured to be ignored', async () => {
const sitemap = await createSitemap( const sitemap = await createSitemap({
{ siteConfig,
url: 'https://example.com', routes: routes([
} as DocusaurusConfig, '/',
['/', '/search/', '/tags/', '/search/foo', '/tags/foo/bar'], '/search/',
{}, '/tags/',
{ '/search/foo',
changefreq: EnumChangefreq.DAILY, '/tags/foo/bar',
priority: 0.7, ]),
head: {},
options: {
...options,
ignorePatterns: [ ignorePatterns: [
// Shallow ignore // Shallow ignore
'/search/', '/search/',
// Deep ignore // Deep ignore
'/tags/**', '/tags/**',
], ],
filename: 'sitemap.xml',
}, },
); });
expect(sitemap).not.toContain('/search/</loc>'); expect(sitemap).not.toContain('/search/</loc>');
expect(sitemap).toContain('/search/foo'); expect(sitemap).toContain('/search/foo');
expect(sitemap).not.toContain('/tags'); expect(sitemap).not.toContain('/tags');
}); });
it('keep trailing slash unchanged', async () => { it('keep trailing slash unchanged', async () => {
const sitemap = await createSitemap( const sitemap = await createSitemap({
{ siteConfig,
url: 'https://example.com', routes: routes(['/', '/test', '/nested/test', '/nested/test2/']),
trailingSlash: undefined, head: {},
} as DocusaurusConfig, options,
['/', '/test', '/nested/test', '/nested/test2/'], });
{},
{
changefreq: EnumChangefreq.DAILY,
priority: 0.7,
ignorePatterns: [],
filename: 'sitemap.xml',
},
);
expect(sitemap).toContain('<loc>https://example.com/</loc>'); expect(sitemap).toContain('<loc>https://example.com/</loc>');
expect(sitemap).toContain('<loc>https://example.com/test</loc>'); expect(sitemap).toContain('<loc>https://example.com/test</loc>');
@ -103,20 +99,12 @@ describe('createSitemap', () => {
}); });
it('add trailing slash', async () => { it('add trailing slash', async () => {
const sitemap = await createSitemap( const sitemap = await createSitemap({
{ siteConfig: {...siteConfig, trailingSlash: true},
url: 'https://example.com', routes: routes(['/', '/test', '/nested/test', '/nested/test2/']),
trailingSlash: true, head: {},
} as DocusaurusConfig, options,
['/', '/test', '/nested/test', '/nested/test2/'], });
{},
{
changefreq: EnumChangefreq.DAILY,
priority: 0.7,
ignorePatterns: [],
filename: 'sitemap.xml',
},
);
expect(sitemap).toContain('<loc>https://example.com/</loc>'); expect(sitemap).toContain('<loc>https://example.com/</loc>');
expect(sitemap).toContain('<loc>https://example.com/test/</loc>'); expect(sitemap).toContain('<loc>https://example.com/test/</loc>');
@ -125,20 +113,16 @@ describe('createSitemap', () => {
}); });
it('remove trailing slash', async () => { it('remove trailing slash', async () => {
const sitemap = await createSitemap( const sitemap = await createSitemap({
{ siteConfig: {
...siteConfig,
url: 'https://example.com', url: 'https://example.com',
trailingSlash: false, trailingSlash: false,
} as DocusaurusConfig,
['/', '/test', '/nested/test', '/nested/test2/'],
{},
{
changefreq: EnumChangefreq.DAILY,
priority: 0.7,
ignorePatterns: [],
filename: 'sitemap.xml',
}, },
); routes: routes(['/', '/test', '/nested/test', '/nested/test2/']),
head: {},
options,
});
expect(sitemap).toContain('<loc>https://example.com/</loc>'); expect(sitemap).toContain('<loc>https://example.com/</loc>');
expect(sitemap).toContain('<loc>https://example.com/test</loc>'); expect(sitemap).toContain('<loc>https://example.com/test</loc>');
@ -147,13 +131,11 @@ describe('createSitemap', () => {
}); });
it('filters pages with noindex', async () => { it('filters pages with noindex', async () => {
const sitemap = await createSitemap( const sitemap = await createSitemap({
{ siteConfig,
url: 'https://example.com', routesPaths: ['/', '/noindex', '/nested/test', '/nested/test2/'],
trailingSlash: false, routes: routes(['/', '/noindex', '/nested/test', '/nested/test2/']),
} as DocusaurusConfig, head: {
['/', '/noindex', '/nested/test', '/nested/test2/'],
{
'/noindex': { '/noindex': {
meta: { meta: {
// @ts-expect-error: bad lib def // @ts-expect-error: bad lib def
@ -166,24 +148,18 @@ describe('createSitemap', () => {
}, },
}, },
}, },
{ options,
changefreq: EnumChangefreq.DAILY, });
priority: 0.7,
ignorePatterns: [],
},
);
expect(sitemap).not.toContain('/noindex'); expect(sitemap).not.toContain('/noindex');
}); });
it('does not generate anything for all pages with noindex', async () => { it('does not generate anything for all pages with noindex', async () => {
const sitemap = await createSitemap( const sitemap = await createSitemap({
{ siteConfig,
url: 'https://example.com', routesPaths: ['/', '/noindex'],
trailingSlash: false, routes: routes(['/', '/noindex']),
} as DocusaurusConfig, head: {
['/', '/noindex'],
{
'/': { '/': {
meta: { meta: {
// @ts-expect-error: bad lib def // @ts-expect-error: bad lib def
@ -201,12 +177,8 @@ describe('createSitemap', () => {
}, },
}, },
}, },
{ options,
changefreq: EnumChangefreq.DAILY, });
priority: 0.7,
ignorePatterns: [],
},
);
expect(sitemap).toBeNull(); expect(sitemap).toBeNull();
}); });

View file

@ -0,0 +1,229 @@
/**
* 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 {fromPartial} from '@total-typescript/shoehorn';
import {createSitemapItem} from '../createSitemapItem';
import {DEFAULT_OPTIONS} from '../options';
import type {PluginOptions} from '../options';
import type {DocusaurusConfig, RouteConfig} from '@docusaurus/types';
const siteConfig: DocusaurusConfig = fromPartial({
url: 'https://example.com',
});
function test(params: {
route: Partial<RouteConfig>;
siteConfig?: Partial<DocusaurusConfig>;
options?: Partial<PluginOptions>;
}) {
return createSitemapItem({
route: params.route as unknown as RouteConfig,
siteConfig: {...siteConfig, ...params.siteConfig},
options: {...DEFAULT_OPTIONS, ...params.options},
});
}
function testRoute(route: Partial<RouteConfig>) {
return test({
route,
});
}
describe('createSitemapItem', () => {
it('simple item', async () => {
await expect(testRoute({path: '/routePath'})).resolves
.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": null,
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
describe('lastmod', () => {
const date = new Date('2024/01/01');
describe('read from route metadata', () => {
const route = {
path: '/routePath',
metadata: {lastUpdatedAt: date.getTime()},
};
it('lastmod default option', async () => {
await expect(
test({
route,
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": null,
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
it('lastmod date option', async () => {
await expect(
test({
route,
options: {
lastmod: 'date',
},
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": "2024-01-01",
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
it('lastmod datetime option', async () => {
await expect(
test({
route,
options: {
lastmod: 'datetime',
},
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": "2024-01-01T00:00:00.000Z",
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
});
describe('read from git', () => {
const route = {
path: '/routePath',
metadata: {sourceFilePath: 'route/file.md'},
};
it('lastmod default option', async () => {
await expect(
test({
route,
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": null,
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
it('lastmod date option', async () => {
await expect(
test({
route,
options: {
lastmod: 'date',
},
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": "2018-10-14",
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
it('lastmod datetime option', async () => {
await expect(
test({
route,
options: {
lastmod: 'datetime',
},
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": "2018-10-14T07:27:35.000Z",
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
});
describe('read from both - route metadata takes precedence', () => {
const route = {
path: '/routePath',
metadata: {
sourceFilePath: 'route/file.md',
lastUpdatedAt: date.getTime(),
},
};
it('lastmod default option', async () => {
await expect(
test({
route,
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": null,
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
it('lastmod date option', async () => {
await expect(
test({
route,
options: {
lastmod: 'date',
},
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": "2024-01-01",
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
it('lastmod datetime option', async () => {
await expect(
test({
route,
options: {
lastmod: 'datetime',
},
}),
).resolves.toMatchInlineSnapshot(`
{
"changefreq": "weekly",
"lastmod": "2024-01-01T00:00:00.000Z",
"priority": 0.5,
"url": "https://example.com/routePath",
}
`);
});
});
});
});

View file

@ -12,7 +12,6 @@ import {
type Options, type Options,
type PluginOptions, type PluginOptions,
} from '../options'; } from '../options';
import type {EnumChangefreq} from 'sitemap';
import type {Validate} from '@docusaurus/types'; import type {Validate} from '@docusaurus/types';
function testValidate(options: Options) { function testValidate(options: Options) {
@ -34,9 +33,10 @@ describe('validateOptions', () => {
it('accepts correctly defined user options', () => { it('accepts correctly defined user options', () => {
const userOptions: Options = { const userOptions: Options = {
changefreq: 'yearly' as EnumChangefreq, changefreq: 'yearly',
priority: 0.9, priority: 0.9,
ignorePatterns: ['/search/**'], ignorePatterns: ['/search/**'],
lastmod: 'datetime',
}; };
expect(testValidate(userOptions)).toEqual({ expect(testValidate(userOptions)).toEqual({
...defaultOptions, ...defaultOptions,
@ -44,32 +44,209 @@ describe('validateOptions', () => {
}); });
}); });
it('rejects out-of-range priority inputs', () => { describe('lastmod', () => {
expect(() => it('accepts lastmod undefined', () => {
testValidate({priority: 2}), const userOptions: Options = {
).toThrowErrorMatchingInlineSnapshot( lastmod: undefined,
`""priority" must be less than or equal to 1"`, };
); expect(testValidate(userOptions)).toEqual(defaultOptions);
});
it('accepts lastmod null', () => {
const userOptions: Options = {
lastmod: null,
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('accepts lastmod datetime', () => {
const userOptions: Options = {
lastmod: 'datetime',
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('rejects lastmod bad input', () => {
const userOptions: Options = {
// @ts-expect-error: bad value on purpose
lastmod: 'dateTimeZone',
};
expect(() =>
testValidate(userOptions),
).toThrowErrorMatchingInlineSnapshot(
`""lastmod" must be one of [null, date, datetime]"`,
);
});
}); });
it('rejects bad changefreq inputs', () => { describe('priority', () => {
expect(() => it('accepts priority undefined', () => {
testValidate({changefreq: 'annually' as EnumChangefreq}), const userOptions: Options = {
).toThrowErrorMatchingInlineSnapshot( priority: undefined,
`""changefreq" must be one of [daily, monthly, always, hourly, weekly, yearly, never]"`, };
); expect(testValidate(userOptions)).toEqual(defaultOptions);
});
it('accepts priority null', () => {
const userOptions: Options = {
priority: null,
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('accepts priority 0', () => {
const userOptions: Options = {
priority: 0,
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('accepts priority 0.4', () => {
const userOptions: Options = {
priority: 0.4,
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('accepts priority 1', () => {
const userOptions: Options = {
priority: 1,
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('rejects priority > 1', () => {
const userOptions: Options = {
priority: 2,
};
expect(() =>
testValidate(userOptions),
).toThrowErrorMatchingInlineSnapshot(
`""priority" must be less than or equal to 1"`,
);
});
it('rejects priority < 0', () => {
const userOptions: Options = {
priority: -3,
};
expect(() =>
testValidate(userOptions),
).toThrowErrorMatchingInlineSnapshot(
`""priority" must be greater than or equal to 0"`,
);
});
}); });
it('rejects bad ignorePatterns inputs', () => { describe('changefreq', () => {
expect(() => it('accepts changefreq undefined', () => {
// @ts-expect-error: test const userOptions: Options = {
testValidate({ignorePatterns: '/search'}), changefreq: undefined,
).toThrowErrorMatchingInlineSnapshot(`""ignorePatterns" must be an array"`); };
expect(() => expect(testValidate(userOptions)).toEqual(defaultOptions);
// @ts-expect-error: test });
testValidate({ignorePatterns: [/^\/search/]}),
).toThrowErrorMatchingInlineSnapshot( it('accepts changefreq null', () => {
`""ignorePatterns[0]" must be a string"`, const userOptions: Options = {
); changefreq: null,
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('accepts changefreq always', () => {
const userOptions: Options = {
changefreq: 'always',
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('rejects changefreq bad inputs', () => {
const userOptions: Options = {
// @ts-expect-error: bad value on purpose
changefreq: 'annually',
};
expect(() =>
testValidate(userOptions),
).toThrowErrorMatchingInlineSnapshot(
`""changefreq" must be one of [null, hourly, daily, weekly, monthly, yearly, always, never]"`,
);
});
});
describe('ignorePatterns', () => {
it('accept ignorePatterns undefined', () => {
const userOptions: Options = {
ignorePatterns: undefined,
};
expect(testValidate(userOptions)).toEqual(defaultOptions);
});
it('accept ignorePatterns empty', () => {
const userOptions: Options = {
ignorePatterns: [],
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('accept ignorePatterns valid', () => {
const userOptions: Options = {
ignorePatterns: ['/tags/**'],
};
expect(testValidate(userOptions)).toEqual({
...defaultOptions,
...userOptions,
});
});
it('rejects ignorePatterns bad input array', () => {
const userOptions: Options = {
// @ts-expect-error: test
ignorePatterns: '/search',
};
expect(() =>
testValidate(userOptions),
).toThrowErrorMatchingInlineSnapshot(
`""ignorePatterns" must be an array"`,
);
});
it('rejects ignorePatterns bad input item string', () => {
const userOptions: Options = {
// @ts-expect-error: test
ignorePatterns: [/^\/search/],
};
expect(() =>
testValidate(userOptions),
).toThrowErrorMatchingInlineSnapshot(
`""ignorePatterns[0]" must be a string"`,
);
});
}); });
}); });

View file

@ -0,0 +1,67 @@
/**
* 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 {sitemapItemsToXmlString} from '../xml';
import type {SitemapItem} from '../types';
const options = {lastmod: 'datetime'} as const;
describe('createSitemap', () => {
it('no items', async () => {
const items: SitemapItem[] = [];
await expect(
sitemapItemsToXmlString(items, options),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Can't generate a sitemap with no items"`,
);
});
it('simple item', async () => {
const items: SitemapItem[] = [{url: 'https://docusaurus.io/docs/doc1'}];
await expect(
sitemapItemsToXmlString(items, options),
).resolves.toMatchInlineSnapshot(
`"<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://docusaurus.io/docs/doc1</loc></url></urlset>"`,
);
});
it('complex item', async () => {
const items: SitemapItem[] = [
{
url: 'https://docusaurus.io/docs/doc1',
changefreq: 'always',
priority: 1,
lastmod: new Date('01/01/2024').toISOString(),
},
];
await expect(
sitemapItemsToXmlString(items, options),
).resolves.toMatchInlineSnapshot(
`"<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://docusaurus.io/docs/doc1</loc><lastmod>2024-01-01T00:00:00.000Z</lastmod><changefreq>always</changefreq><priority>1.0</priority></url></urlset>"`,
);
});
it('date only lastmod', async () => {
const items: SitemapItem[] = [
{
url: 'https://docusaurus.io/docs/doc1',
changefreq: 'always',
priority: 1,
lastmod: new Date('01/01/2024').toISOString(),
},
];
await expect(
sitemapItemsToXmlString(items, {lastmod: 'date'}),
).resolves.toMatchInlineSnapshot(
`"<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://docusaurus.io/docs/doc1</loc><lastmod>2024-01-01</lastmod><changefreq>always</changefreq><priority>1.0</priority></url></urlset>"`,
);
});
});

View file

@ -6,13 +6,23 @@
*/ */
import type {ReactElement} from 'react'; import type {ReactElement} from 'react';
import {SitemapStream, streamToPromise} from 'sitemap'; import {createMatcher, flattenRoutes} from '@docusaurus/utils';
import {applyTrailingSlash} from '@docusaurus/utils-common'; import {sitemapItemsToXmlString} from './xml';
import {createMatcher} from '@docusaurus/utils'; import {createSitemapItem} from './createSitemapItem';
import type {DocusaurusConfig} from '@docusaurus/types'; import type {SitemapItem} from './types';
import type {DocusaurusConfig, RouteConfig} from '@docusaurus/types';
import type {HelmetServerState} from 'react-helmet-async'; import type {HelmetServerState} from 'react-helmet-async';
import type {PluginOptions} from './options'; 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({ function isNoIndexMetaRoute({
head, head,
route, route,
@ -47,50 +57,51 @@ function isNoIndexMetaRoute({
); );
} }
export default async function createSitemap( // Not all routes should appear in the sitemap, and we should filter:
siteConfig: DocusaurusConfig, // - parent routes, used for layouts
routesPaths: string[], // - routes matching options.ignorePatterns
head: {[location: string]: HelmetServerState}, // - routes with no index metadata
options: PluginOptions, function getSitemapRoutes({routes, head, options}: CreateSitemapParams) {
): Promise<string | null> { const {ignorePatterns} = options;
const {url: hostname} = siteConfig;
if (!hostname) {
throw new Error('URL in docusaurus.config.js cannot be empty/undefined.');
}
const {changefreq, priority, ignorePatterns} = options;
const ignoreMatcher = createMatcher(ignorePatterns); const ignoreMatcher = createMatcher(ignorePatterns);
function isRouteExcluded(route: string) { function isRouteExcluded(route: RouteConfig) {
return ( return (
route.endsWith('404.html') || ignoreMatcher(route.path) || isNoIndexMetaRoute({head, route: route.path})
ignoreMatcher(route) ||
isNoIndexMetaRoute({head, route})
); );
} }
const includedRoutes = routesPaths.filter((route) => !isRouteExcluded(route)); return flattenRoutes(routes).filter((route) => !isRouteExcluded(route));
}
if (includedRoutes.length === 0) { 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,
}),
),
);
}
export default async function createSitemap(
params: CreateSitemapParams,
): Promise<string | null> {
const items = await createSitemapItems(params);
if (items.length === 0) {
return null; return null;
} }
const xmlString = await sitemapItemsToXmlString(items, {
const sitemapStream = new SitemapStream({hostname}); lastmod: params.options.lastmod,
});
includedRoutes.forEach((routePath) => return xmlString;
sitemapStream.write({
url: applyTrailingSlash(routePath, {
trailingSlash: siteConfig.trailingSlash,
baseUrl: siteConfig.baseUrl,
}),
changefreq,
priority,
}),
);
sitemapStream.end();
const generatedSitemap = (await streamToPromise(sitemapStream)).toString();
return generatedSitemap;
} }

View file

@ -0,0 +1,76 @@
/**
* 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 {applyTrailingSlash} from '@docusaurus/utils-common';
import {getLastUpdate, normalizeUrl} from '@docusaurus/utils';
import type {LastModOption, SitemapItem} from './types';
import type {DocusaurusConfig, RouteConfig} from '@docusaurus/types';
import type {PluginOptions} from './options';
async function getRouteLastUpdatedAt(
route: RouteConfig,
): Promise<number | undefined> {
if (route.metadata?.lastUpdatedAt) {
return route.metadata?.lastUpdatedAt;
}
if (route.metadata?.sourceFilePath) {
const lastUpdate = await getLastUpdate(route.metadata?.sourceFilePath);
return lastUpdate?.lastUpdatedAt;
}
return undefined;
}
type LastModFormatter = (timestamp: number) => string;
const LastmodFormatters: Record<LastModOption, LastModFormatter> = {
date: (timestamp) => new Date(timestamp).toISOString().split('T')[0]!,
datetime: (timestamp) => new Date(timestamp).toISOString(),
};
function formatLastmod(timestamp: number, lastmodOption: LastModOption) {
const format = LastmodFormatters[lastmodOption];
return format(timestamp);
}
async function getRouteLastmod({
route,
lastmod,
}: {
route: RouteConfig;
lastmod: LastModOption | null;
}): Promise<string | null> {
if (lastmod === null) {
return null;
}
const lastUpdatedAt = (await getRouteLastUpdatedAt(route)) ?? null;
return lastUpdatedAt ? formatLastmod(lastUpdatedAt, lastmod) : null;
}
export async function createSitemapItem({
route,
siteConfig,
options,
}: {
route: RouteConfig;
siteConfig: DocusaurusConfig;
options: PluginOptions;
}): Promise<SitemapItem> {
const {changefreq, priority, lastmod} = options;
return {
url: normalizeUrl([
siteConfig.url,
applyTrailingSlash(route.path, {
trailingSlash: siteConfig.trailingSlash,
baseUrl: siteConfig.baseUrl,
}),
]),
changefreq,
priority,
lastmod: await getRouteLastmod({route, lastmod}),
};
}

View file

@ -19,17 +19,17 @@ export default function pluginSitemap(
return { return {
name: 'docusaurus-plugin-sitemap', name: 'docusaurus-plugin-sitemap',
async postBuild({siteConfig, routesPaths, outDir, head}) { async postBuild({siteConfig, routes, outDir, head}) {
if (siteConfig.noIndex) { if (siteConfig.noIndex) {
return; return;
} }
// Generate sitemap. // Generate sitemap.
const generatedSitemap = await createSitemap( const generatedSitemap = await createSitemap({
siteConfig, siteConfig,
routesPaths, routes,
head, head,
options, options,
); });
if (!generatedSitemap) { if (!generatedSitemap) {
return; return;
} }

View file

@ -6,33 +6,60 @@
*/ */
import {Joi} from '@docusaurus/utils-validation'; import {Joi} from '@docusaurus/utils-validation';
import {EnumChangefreq} from 'sitemap'; import {ChangeFreqList, LastModOptionList} from './types';
import type {OptionValidationContext} from '@docusaurus/types'; import type {OptionValidationContext} from '@docusaurus/types';
import type {ChangeFreq, LastModOption} from './types';
export type PluginOptions = { export type PluginOptions = {
/** @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions */
changefreq: EnumChangefreq;
/** @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions */
priority: number;
/**
* 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[];
/** /**
* The path to the created sitemap file, relative to the output directory. * The path to the created sitemap file, relative to the output directory.
* Useful if you have two plugin instances outputting two files. * Useful if you have two plugin instances outputting two files.
*/ */
filename: string; filename: 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[];
/**
* Defines the format of the "lastmod" sitemap item entry, between:
* - null: do not compute/add a "lastmod" sitemap entry
* - "date": add a "lastmod" sitemap entry without time (YYYY-MM-DD)
* - "datetime": add a "lastmod" sitemap entry with time (ISO 8601 datetime)
* @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
* @see https://www.w3.org/TR/NOTE-datetime
*/
lastmod: LastModOption | null;
/**
* TODO Docusaurus v4 breaking change: remove useless option
* @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
*/
changefreq: ChangeFreq | null;
/**
* TODO Docusaurus v4 breaking change: remove useless option
* @see https://www.sitemaps.org/protocol.html#xmlTagDefinitions
*/
priority: number | null;
}; };
export type Options = Partial<PluginOptions>; export type Options = Partial<PluginOptions>;
export const DEFAULT_OPTIONS: PluginOptions = { export const DEFAULT_OPTIONS: PluginOptions = {
changefreq: EnumChangefreq.WEEKLY,
priority: 0.5,
ignorePatterns: [],
filename: 'sitemap.xml', filename: 'sitemap.xml',
ignorePatterns: [],
// TODO Docusaurus v4 breaking change
// change default to "date" if no bug or perf issue reported
lastmod: null,
// TODO Docusaurus v4 breaking change
// those options are useless and should be removed
changefreq: 'weekly',
priority: 0.5,
}; };
const PluginOptionSchema = Joi.object<PluginOptions>({ const PluginOptionSchema = Joi.object<PluginOptions>({
@ -41,10 +68,28 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
'any.unknown': 'any.unknown':
'Option `cacheTime` in sitemap config is deprecated. Please remove it.', 'Option `cacheTime` in sitemap config is deprecated. Please remove it.',
}), }),
// TODO remove for Docusaurus v4 breaking changes?
// This is not even used by Google crawlers
// See also https://github.com/facebook/docusaurus/issues/2604
changefreq: Joi.string() changefreq: Joi.string()
.valid(...Object.values(EnumChangefreq)) .valid(null, ...ChangeFreqList)
.default(DEFAULT_OPTIONS.changefreq), .default(DEFAULT_OPTIONS.changefreq),
priority: Joi.number().min(0).max(1).default(DEFAULT_OPTIONS.priority),
// TODO remove for Docusaurus v4 breaking changes?
// This is not even used by Google crawlers
// The priority is "relative", and using the same priority for all routes
// does not make sense according to the spec
// See also https://github.com/facebook/docusaurus/issues/2604
// See also https://www.sitemaps.org/protocol.html
priority: Joi.alternatives()
.try(Joi.valid(null), Joi.number().min(0).max(1))
.default(DEFAULT_OPTIONS.priority),
lastmod: Joi.string()
.valid(null, ...LastModOptionList)
.default(DEFAULT_OPTIONS.lastmod),
ignorePatterns: Joi.array() ignorePatterns: Joi.array()
.items(Joi.string()) .items(Joi.string())
.default(DEFAULT_OPTIONS.ignorePatterns), .default(DEFAULT_OPTIONS.ignorePatterns),

View file

@ -0,0 +1,67 @@
/**
* 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.
*/
export const LastModOptionList = ['date', 'datetime'] as const;
export type LastModOption = (typeof LastModOptionList)[number];
// types are according to the sitemap spec:
// see also https://www.sitemaps.org/protocol.html
export const ChangeFreqList = [
'hourly',
'daily',
'weekly',
'monthly',
'yearly',
'always',
'never',
] as const;
export type ChangeFreq = (typeof ChangeFreqList)[number];
// We re-recreate our own type because the "sitemap" lib types are not good
export type SitemapItem = {
/**
* URL of the page.
* This URL must begin with the protocol (such as http).
* It should eventually end with a trailing slash.
* It should be less than 2,048 characters.
*/
url: string;
/**
* ISO 8601 date string.
* See also https://www.w3.org/TR/NOTE-datetime
*
* It is recommended to use one of:
* - date.toISOString()
* - YYYY-MM-DD
*
* Note: as of 2024, Google uses this value for crawling priority.
* See also https://github.com/facebook/docusaurus/issues/2604
*/
lastmod?: string | null;
/**
* One of the specified enum values
*
* Note: as of 2024, Google ignores this value.
* See also https://github.com/facebook/docusaurus/issues/2604
*/
changefreq?: ChangeFreq | null;
/**
* The priority of this URL relative to other URLs on your site.
* Valid values range from 0.0 to 1.0.
* The default priority of a page is 0.5.
*
* Note: as of 2024, Google ignores this value.
* See also https://github.com/facebook/docusaurus/issues/2604
*/
priority?: number | null;
};

View file

@ -0,0 +1,35 @@
/**
* 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 {SitemapStream, streamToPromise} from 'sitemap';
import type {LastModOption, SitemapItem} from './types';
export async function sitemapItemsToXmlString(
items: SitemapItem[],
options: {lastmod: LastModOption | null},
): Promise<string> {
if (items.length === 0) {
// Note: technically we could, but there is a bug in the lib code
// and the code below would never resolve, so it's better to fail fast
throw new Error("Can't generate a sitemap with no items");
}
// TODO remove sitemap lib dependency?
// https://github.com/ekalinin/sitemap.js
// it looks like an outdated confusion super old lib
// we might as well achieve the same result with a pure xml lib
const sitemapStream = new SitemapStream({
// WTF is this lib reformatting the string YYYY-MM-DD to datetime...
lastmodDateOnly: options?.lastmod === 'date',
});
items.forEach((item) => sitemapStream.write(item));
sitemapStream.end();
const buffer = await streamToPromise(sitemapStream);
return buffer.toString();
}

View file

@ -45,6 +45,7 @@ export {
export { export {
Plugin, Plugin,
PluginIdentifier,
InitializedPlugin, InitializedPlugin,
LoadedPlugin, LoadedPlugin,
PluginModule, PluginModule,
@ -69,6 +70,7 @@ export {
export { export {
RouteConfig, RouteConfig,
RouteMetadata,
RouteContext, RouteContext,
PluginRouteContext, PluginRouteContext,
Registry, Registry,

View file

@ -11,7 +11,7 @@ import type {ParsedUrlQueryInput} from 'querystring';
* A "module" represents a unit of serialized data emitted from the plugin. It * A "module" represents a unit of serialized data emitted from the plugin. It
* will be imported on client-side and passed as props, context, etc. * will be imported on client-side and passed as props, context, etc.
* *
* If it's a string, it's a file path that Webpack can `require`; if it's * If it's a string, it's a file path that the bundler can `require`; if it's
* an object, it can also contain `query` or other metadata. * an object, it can also contain `query` or other metadata.
*/ */
export type Module = export type Module =
@ -36,14 +36,45 @@ export type RouteModules = {
[propName: string]: Module | RouteModules | RouteModules[]; [propName: string]: Module | RouteModules | RouteModules[];
}; };
/**
* Plugin authors can assign extra metadata to the created routes
* It is only available on the Node.js side, and not sent to the browser
* Optional: plugin authors are encouraged but not required to provide it
*
* Some plugins might use this data to provide additional features.
* This is the case of the sitemap plugin to provide support for "lastmod".
* See also: https://github.com/facebook/docusaurus/pull/9954
*/
export type RouteMetadata = {
/**
* The source code file path that led to the creation of the current route
* In official content plugins, this is usually a Markdown or React file
* This path is expected to be relative to the site directory
*/
sourceFilePath?: string;
/**
* The last updated date of this route
* This is generally read from the Git history of the sourceFilePath
* but can also be provided through other means (usually front matter)
*
* This has notably been introduced for adding "lastmod" support to the
* sitemap plugin, see https://github.com/facebook/docusaurus/pull/9954
*/
lastUpdatedAt?: number;
};
/** /**
* Represents a "slice" of the final route structure returned from the plugin * Represents a "slice" of the final route structure returned from the plugin
* `addRoute` action. * `addRoute` action.
*/ */
export type RouteConfig = { export type RouteConfig = {
/** With leading slash. Trailing slash will be normalized by config. */ /**
* With leading slash. Trailing slash will be normalized by config.
*/
path: string; path: string;
/** Component used to render this route, a path that Webpack can `require`. */ /**
* Component used to render this route, a path that the bundler can `require`.
*/
component: string; component: string;
/** /**
* Props. Each entry should be `[propName]: pathToPropModule` (created with * Props. Each entry should be `[propName]: pathToPropModule` (created with
@ -56,18 +87,31 @@ export type RouteConfig = {
* here will be namespaced under {@link RouteContext.data}. * here will be namespaced under {@link RouteContext.data}.
*/ */
context?: RouteModules; context?: RouteModules;
/** Nested routes config. */ /**
* Nested routes config, useful for "layout routes" having subroutes.
*/
routes?: RouteConfig[]; routes?: RouteConfig[];
/** React router config option: `exact` routes would not match subroutes. */ /**
* React router config option: `exact` routes would not match subroutes.
*/
exact?: boolean; exact?: boolean;
/** /**
* React router config option: `strict` routes are sensitive to the presence * React router config option: `strict` routes are sensitive to the presence
* of a trailing slash. * of a trailing slash.
*/ */
strict?: boolean; strict?: boolean;
/** Used to sort routes. Higher-priority routes will be placed first. */ /**
* Used to sort routes.
* Higher-priority routes will be matched first.
*/
priority?: number; priority?: number;
/** Extra props; will be copied to routes.js. */ /**
* Optional route metadata
*/
metadata?: RouteMetadata;
/**
* Extra props; will be available on the client side.
*/
[propName: string]: unknown; [propName: string]: unknown;
}; };

View file

@ -9,7 +9,7 @@ import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import {createTempRepo} from '@testing-utils/git'; import {createTempRepo} from '@testing-utils/git';
import {FileNotTrackedError, getFileCommitDate} from '../gitUtils'; import {FileNotTrackedError, getFileCommitDate} from '../gitUtils';
import {getFileLastUpdate} from '../lastUpdateUtils'; import {getGitLastUpdate} from '../lastUpdateUtils';
/* eslint-disable no-restricted-properties */ /* eslint-disable no-restricted-properties */
function initializeTempRepo() { function initializeTempRepo() {
@ -146,8 +146,9 @@ describe('getFileCommitDate', () => {
const tempFilePath2 = path.join(repoDir, 'file2.md'); const tempFilePath2 = path.join(repoDir, 'file2.md');
await fs.writeFile(tempFilePath1, 'Lorem ipsum :)'); await fs.writeFile(tempFilePath1, 'Lorem ipsum :)');
await fs.writeFile(tempFilePath2, 'Lorem ipsum :)'); await fs.writeFile(tempFilePath2, 'Lorem ipsum :)');
await expect(getFileLastUpdate(tempFilePath1)).resolves.toBeNull(); // TODO this is not the correct place to test "getGitLastUpdate"
await expect(getFileLastUpdate(tempFilePath2)).resolves.toBeNull(); await expect(getGitLastUpdate(tempFilePath1)).resolves.toBeNull();
await expect(getGitLastUpdate(tempFilePath2)).resolves.toBeNull();
expect(consoleMock).toHaveBeenCalledTimes(1); expect(consoleMock).toHaveBeenCalledTimes(1);
expect(consoleMock).toHaveBeenLastCalledWith( expect(consoleMock).toHaveBeenLastCalledWith(
expect.stringMatching(/not tracked by git./), expect.stringMatching(/not tracked by git./),

View file

@ -11,13 +11,12 @@ import path from 'path';
import {createTempRepo} from '@testing-utils/git'; import {createTempRepo} from '@testing-utils/git';
import shell from 'shelljs'; import shell from 'shelljs';
import { import {
getFileLastUpdate, getGitLastUpdate,
GIT_FALLBACK_LAST_UPDATE_AUTHOR, LAST_UPDATE_FALLBACK,
GIT_FALLBACK_LAST_UPDATE_DATE,
readLastUpdateData, readLastUpdateData,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
describe('getFileLastUpdate', () => { describe('getGitLastUpdate', () => {
const {repoDir} = createTempRepo(); const {repoDir} = createTempRepo();
const existingFilePath = path.join( const existingFilePath = path.join(
@ -25,15 +24,15 @@ describe('getFileLastUpdate', () => {
'__fixtures__/simple-site/hello.md', '__fixtures__/simple-site/hello.md',
); );
it('existing test file in repository with Git timestamp', async () => { it('existing test file in repository with Git timestamp', async () => {
const lastUpdateData = await getFileLastUpdate(existingFilePath); const lastUpdateData = await getGitLastUpdate(existingFilePath);
expect(lastUpdateData).not.toBeNull(); expect(lastUpdateData).not.toBeNull();
const {author, timestamp} = lastUpdateData!; const {lastUpdatedAt, lastUpdatedBy} = lastUpdateData!;
expect(author).not.toBeNull(); expect(lastUpdatedBy).not.toBeNull();
expect(typeof author).toBe('string'); expect(typeof lastUpdatedBy).toBe('string');
expect(timestamp).not.toBeNull(); expect(lastUpdatedAt).not.toBeNull();
expect(typeof timestamp).toBe('number'); expect(typeof lastUpdatedAt).toBe('number');
}); });
it('existing test file with spaces in path', async () => { it('existing test file with spaces in path', async () => {
@ -41,15 +40,15 @@ describe('getFileLastUpdate', () => {
__dirname, __dirname,
'__fixtures__/simple-site/doc with space.md', '__fixtures__/simple-site/doc with space.md',
); );
const lastUpdateData = await getFileLastUpdate(filePathWithSpace); const lastUpdateData = await getGitLastUpdate(filePathWithSpace);
expect(lastUpdateData).not.toBeNull(); expect(lastUpdateData).not.toBeNull();
const {author, timestamp} = lastUpdateData!; const {lastUpdatedBy, lastUpdatedAt} = lastUpdateData!;
expect(author).not.toBeNull(); expect(lastUpdatedBy).not.toBeNull();
expect(typeof author).toBe('string'); expect(typeof lastUpdatedBy).toBe('string');
expect(timestamp).not.toBeNull(); expect(lastUpdatedAt).not.toBeNull();
expect(typeof timestamp).toBe('number'); expect(typeof lastUpdatedAt).toBe('number');
}); });
it('non-existing file', async () => { it('non-existing file', async () => {
@ -62,7 +61,7 @@ describe('getFileLastUpdate', () => {
'__fixtures__', '__fixtures__',
nonExistingFileName, nonExistingFileName,
); );
await expect(getFileLastUpdate(nonExistingFilePath)).rejects.toThrow( await expect(getGitLastUpdate(nonExistingFilePath)).rejects.toThrow(
/An error occurred when trying to get the last update date/, /An error occurred when trying to get the last update date/,
); );
expect(consoleMock).toHaveBeenCalledTimes(0); expect(consoleMock).toHaveBeenCalledTimes(0);
@ -74,7 +73,7 @@ describe('getFileLastUpdate', () => {
const consoleMock = jest const consoleMock = jest
.spyOn(console, 'warn') .spyOn(console, 'warn')
.mockImplementation(() => {}); .mockImplementation(() => {});
const lastUpdateData = await getFileLastUpdate(existingFilePath); const lastUpdateData = await getGitLastUpdate(existingFilePath);
expect(lastUpdateData).toBeNull(); expect(lastUpdateData).toBeNull();
expect(consoleMock).toHaveBeenLastCalledWith( expect(consoleMock).toHaveBeenLastCalledWith(
expect.stringMatching( expect.stringMatching(
@ -92,7 +91,7 @@ describe('getFileLastUpdate', () => {
.mockImplementation(() => {}); .mockImplementation(() => {});
const tempFilePath = path.join(repoDir, 'file.md'); const tempFilePath = path.join(repoDir, 'file.md');
await fs.writeFile(tempFilePath, 'Lorem ipsum :)'); await fs.writeFile(tempFilePath, 'Lorem ipsum :)');
await expect(getFileLastUpdate(tempFilePath)).resolves.toBeNull(); await expect(getGitLastUpdate(tempFilePath)).resolves.toBeNull();
expect(consoleMock).toHaveBeenCalledTimes(1); expect(consoleMock).toHaveBeenCalledTimes(1);
expect(consoleMock).toHaveBeenLastCalledWith( expect(consoleMock).toHaveBeenLastCalledWith(
expect.stringMatching(/not tracked by git./), expect.stringMatching(/not tracked by git./),
@ -113,7 +112,7 @@ describe('readLastUpdateData', () => {
{date: testDate}, {date: testDate},
); );
expect(lastUpdatedAt).toEqual(testTimestamp); expect(lastUpdatedAt).toEqual(testTimestamp);
expect(lastUpdatedBy).toBe(GIT_FALLBACK_LAST_UPDATE_AUTHOR); expect(lastUpdatedBy).toBe(LAST_UPDATE_FALLBACK.lastUpdatedBy);
}); });
it('read last author show author time', async () => { it('read last author show author time', async () => {
@ -123,7 +122,7 @@ describe('readLastUpdateData', () => {
{author: testAuthor}, {author: testAuthor},
); );
expect(lastUpdatedBy).toEqual(testAuthor); expect(lastUpdatedBy).toEqual(testAuthor);
expect(lastUpdatedAt).toBe(GIT_FALLBACK_LAST_UPDATE_DATE); expect(lastUpdatedAt).toBe(LAST_UPDATE_FALLBACK.lastUpdatedAt);
}); });
it('read last all show author time', async () => { it('read last all show author time', async () => {
@ -160,7 +159,7 @@ describe('readLastUpdateData', () => {
{showLastUpdateAuthor: true, showLastUpdateTime: false}, {showLastUpdateAuthor: true, showLastUpdateTime: false},
{date: testDate}, {date: testDate},
); );
expect(lastUpdatedBy).toBe(GIT_FALLBACK_LAST_UPDATE_AUTHOR); expect(lastUpdatedBy).toBe(LAST_UPDATE_FALLBACK.lastUpdatedBy);
expect(lastUpdatedAt).toBeUndefined(); expect(lastUpdatedAt).toBeUndefined();
}); });
@ -180,7 +179,7 @@ describe('readLastUpdateData', () => {
{showLastUpdateAuthor: true, showLastUpdateTime: false}, {showLastUpdateAuthor: true, showLastUpdateTime: false},
{}, {},
); );
expect(lastUpdatedBy).toBe(GIT_FALLBACK_LAST_UPDATE_AUTHOR); expect(lastUpdatedBy).toBe(LAST_UPDATE_FALLBACK.lastUpdatedBy);
expect(lastUpdatedAt).toBeUndefined(); expect(lastUpdatedAt).toBeUndefined();
}); });
@ -201,7 +200,7 @@ describe('readLastUpdateData', () => {
{author: testAuthor}, {author: testAuthor},
); );
expect(lastUpdatedBy).toBeUndefined(); expect(lastUpdatedBy).toBeUndefined();
expect(lastUpdatedAt).toEqual(GIT_FALLBACK_LAST_UPDATE_DATE); expect(lastUpdatedAt).toEqual(LAST_UPDATE_FALLBACK.lastUpdatedAt);
}); });
it('read last author show time only - both front matter', async () => { it('read last author show time only - both front matter', async () => {

View file

@ -15,6 +15,7 @@ import {
aliasedSitePath, aliasedSitePath,
toMessageRelativeFilePath, toMessageRelativeFilePath,
addTrailingPathSeparator, addTrailingPathSeparator,
aliasedSitePathToRelativePath,
} from '../pathUtils'; } from '../pathUtils';
describe('isNameTooLong', () => { describe('isNameTooLong', () => {
@ -185,6 +186,20 @@ describe('aliasedSitePath', () => {
}); });
}); });
describe('aliasedSitePathToRelativePath', () => {
it('works', () => {
expect(aliasedSitePathToRelativePath('@site/site/relative/path')).toBe(
'site/relative/path',
);
});
it('is fail-fast', () => {
expect(() => aliasedSitePathToRelativePath('/site/relative/path')).toThrow(
/Unexpected, filePath is not site-aliased: \/site\/relative\/path/,
);
});
});
describe('addTrailingPathSeparator', () => { describe('addTrailingPathSeparator', () => {
it('works', () => { it('works', () => {
expect(addTrailingPathSeparator('foo')).toEqual( expect(addTrailingPathSeparator('foo')).toEqual(

View file

@ -0,0 +1,33 @@
/**
* 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 {flattenRoutes} from '../routeUtils';
import type {RouteConfig} from '@docusaurus/types';
describe('flattenRoutes', () => {
it('returns flattened routes without parents', () => {
const routes: RouteConfig[] = [
{
path: '/docs',
component: '',
routes: [
{path: '/docs/someDoc', component: ''},
{path: '/docs/someOtherDoc', component: ''},
],
},
{
path: '/community',
component: '',
},
];
expect(flattenRoutes(routes)).toEqual([
routes[0]!.routes![0],
routes[0]!.routes![1],
routes[1],
]);
});
});

View file

@ -91,6 +91,7 @@ export {
posixPath, posixPath,
toMessageRelativeFilePath, toMessageRelativeFilePath,
aliasedSitePath, aliasedSitePath,
aliasedSitePathToRelativePath,
escapePath, escapePath,
addTrailingPathSeparator, addTrailingPathSeparator,
} from './pathUtils'; } from './pathUtils';
@ -118,12 +119,13 @@ export {
export {isDraft, isUnlisted} from './contentVisibilityUtils'; export {isDraft, isUnlisted} from './contentVisibilityUtils';
export {escapeRegexp} from './regExpUtils'; export {escapeRegexp} from './regExpUtils';
export {askPreferredLanguage} from './cliUtils'; export {askPreferredLanguage} from './cliUtils';
export {flattenRoutes} from './routeUtils';
export { export {
getFileLastUpdate, getGitLastUpdate,
getLastUpdate,
readLastUpdateData,
LAST_UPDATE_FALLBACK,
type LastUpdateData, type LastUpdateData,
type FrontMatterLastUpdate, type FrontMatterLastUpdate,
readLastUpdateData,
GIT_FALLBACK_LAST_UPDATE_AUTHOR,
GIT_FALLBACK_LAST_UPDATE_DATE,
} from './lastUpdateUtils'; } from './lastUpdateUtils';

View file

@ -14,22 +14,6 @@ import {
} from './gitUtils'; } from './gitUtils';
import type {PluginOptions} from '@docusaurus/types'; import type {PluginOptions} from '@docusaurus/types';
export const GIT_FALLBACK_LAST_UPDATE_DATE = 1539502055000;
export const GIT_FALLBACK_LAST_UPDATE_AUTHOR = 'Author';
async function getGitLastUpdate(filePath: string): Promise<LastUpdateData> {
if (process.env.NODE_ENV !== 'production') {
// Use fake data in dev/test for faster development.
return {
lastUpdatedBy: GIT_FALLBACK_LAST_UPDATE_AUTHOR,
lastUpdatedAt: GIT_FALLBACK_LAST_UPDATE_DATE,
};
}
const {author, timestamp} = (await getFileLastUpdate(filePath)) ?? {};
return {lastUpdatedBy: author, lastUpdatedAt: timestamp};
}
export type LastUpdateData = { export type LastUpdateData = {
/** A timestamp in **milliseconds**, usually read from `git log` */ /** A timestamp in **milliseconds**, usually read from `git log` */
lastUpdatedAt?: number; lastUpdatedAt?: number;
@ -37,20 +21,12 @@ export type LastUpdateData = {
lastUpdatedBy?: string; lastUpdatedBy?: string;
}; };
export type FrontMatterLastUpdate = {
author?: string;
/** Date can be any
* [parsable date string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse).
*/
date?: Date | string;
};
let showedGitRequirementError = false; let showedGitRequirementError = false;
let showedFileNotTrackedError = false; let showedFileNotTrackedError = false;
export async function getFileLastUpdate( export async function getGitLastUpdate(
filePath: string, filePath: string,
): Promise<{timestamp: number; author: string} | null> { ): Promise<LastUpdateData | null> {
if (!filePath) { if (!filePath) {
return null; return null;
} }
@ -63,7 +39,7 @@ export async function getFileLastUpdate(
includeAuthor: true, includeAuthor: true,
}); });
return {timestamp: result.timestamp, author: result.author}; return {lastUpdatedAt: result.timestamp, lastUpdatedBy: result.author};
} catch (err) { } catch (err) {
if (err instanceof GitNotFoundError) { if (err instanceof GitNotFoundError) {
if (!showedGitRequirementError) { if (!showedGitRequirementError) {
@ -87,11 +63,35 @@ export async function getFileLastUpdate(
} }
} }
export const LAST_UPDATE_FALLBACK: LastUpdateData = {
lastUpdatedAt: 1539502055000,
lastUpdatedBy: 'Author',
};
export async function getLastUpdate(
filePath: string,
): Promise<LastUpdateData | null> {
if (process.env.NODE_ENV !== 'production') {
// Use fake data in dev/test for faster development.
return LAST_UPDATE_FALLBACK;
}
return getGitLastUpdate(filePath);
}
type LastUpdateOptions = Pick< type LastUpdateOptions = Pick<
PluginOptions, PluginOptions,
'showLastUpdateAuthor' | 'showLastUpdateTime' 'showLastUpdateAuthor' | 'showLastUpdateTime'
>; >;
export type FrontMatterLastUpdate = {
author?: string;
/**
* Date can be any
* [parsable date string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse).
*/
date?: Date | string;
};
export async function readLastUpdateData( export async function readLastUpdateData(
filePath: string, filePath: string,
options: LastUpdateOptions, options: LastUpdateOptions,
@ -111,18 +111,18 @@ export async function readLastUpdateData(
// We try to minimize git last update calls // We try to minimize git last update calls
// We call it at most once // We call it at most once
// If all the data is provided as front matter, we do not call it // If all the data is provided as front matter, we do not call it
const getGitLastUpdateMemoized = _.memoize(() => getGitLastUpdate(filePath)); const getLastUpdateMemoized = _.memoize(() => getLastUpdate(filePath));
const getGitLastUpdateBy = () => const getLastUpdateBy = () =>
getGitLastUpdateMemoized().then((update) => update.lastUpdatedBy); getLastUpdateMemoized().then((update) => update?.lastUpdatedBy);
const getGitLastUpdateAt = () => const getLastUpdateAt = () =>
getGitLastUpdateMemoized().then((update) => update.lastUpdatedAt); getLastUpdateMemoized().then((update) => update?.lastUpdatedAt);
const lastUpdatedBy = showLastUpdateAuthor const lastUpdatedBy = showLastUpdateAuthor
? frontMatterAuthor ?? (await getGitLastUpdateBy()) ? frontMatterAuthor ?? (await getLastUpdateBy())
: undefined; : undefined;
const lastUpdatedAt = showLastUpdateTime const lastUpdatedAt = showLastUpdateTime
? frontMatterTimestamp ?? (await getGitLastUpdateAt()) ? frontMatterTimestamp ?? (await getLastUpdateAt())
: undefined; : undefined;
return { return {

View file

@ -92,6 +92,20 @@ export function aliasedSitePath(filePath: string, siteDir: string): string {
return `@site/${relativePath}`; return `@site/${relativePath}`;
} }
/**
* Converts back the aliased site path (starting with "@site/...") to a relative path
*
* TODO method this is a workaround, we shouldn't need to alias/un-alias paths
* we should refactor the codebase to not have aliased site paths everywhere
* We probably only need aliasing for client-only paths required by Webpack
*/
export function aliasedSitePathToRelativePath(filePath: string): string {
if (filePath.startsWith('@site/')) {
return filePath.replace('@site/', '');
}
throw new Error(`Unexpected, filePath is not site-aliased: ${filePath}`);
}
/** /**
* When you have a path like C:\X\Y * When you have a path like C:\X\Y
* It is not safe to use directly when generating code * It is not safe to use directly when generating code

View file

@ -0,0 +1,19 @@
/**
* 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 {RouteConfig} from '@docusaurus/types';
/**
* Recursively flatten routes and only return the "leaf routes"
* Parent routes are filtered out
*/
export function flattenRoutes(routeConfig: RouteConfig[]): RouteConfig[] {
function flatten(route: RouteConfig): RouteConfig[] {
return route.routes ? route.routes.flatMap(flatten) : [route];
}
return routeConfig.flatMap(flatten);
}

View file

@ -6,33 +6,9 @@
*/ */
import {jest} from '@jest/globals'; import {jest} from '@jest/globals';
import {getAllFinalRoutes, handleDuplicateRoutes} from '../routes'; import {handleDuplicateRoutes} from '../routes';
import type {RouteConfig} from '@docusaurus/types'; import type {RouteConfig} from '@docusaurus/types';
describe('getAllFinalRoutes', () => {
it('gets final routes correctly', () => {
const routes: RouteConfig[] = [
{
path: '/docs',
component: '',
routes: [
{path: '/docs/someDoc', component: ''},
{path: '/docs/someOtherDoc', component: ''},
],
},
{
path: '/community',
component: '',
},
];
expect(getAllFinalRoutes(routes)).toEqual([
routes[0]!.routes![0],
routes[0]!.routes![1],
routes[1],
]);
});
});
describe('handleDuplicateRoutes', () => { describe('handleDuplicateRoutes', () => {
const routes: RouteConfig[] = [ const routes: RouteConfig[] = [
{ {

View file

@ -13,9 +13,9 @@ import {
parseURLPath, parseURLPath,
removeTrailingSlash, removeTrailingSlash,
serializeURLPath, serializeURLPath,
flattenRoutes,
type URLPath, type URLPath,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import {getAllFinalRoutes} from './routes';
import type {RouteConfig, ReportingSeverity} from '@docusaurus/types'; import type {RouteConfig, ReportingSeverity} from '@docusaurus/types';
function matchRoutes(routeConfig: RouteConfig[], pathname: string) { function matchRoutes(routeConfig: RouteConfig[], pathname: string) {
@ -188,7 +188,7 @@ function getBrokenLinksForPage({
*/ */
function filterIntermediateRoutes(routesInput: RouteConfig[]): RouteConfig[] { function filterIntermediateRoutes(routesInput: RouteConfig[]): RouteConfig[] {
const routesWithout404 = routesInput.filter((route) => route.path !== '*'); const routesWithout404 = routesInput.filter((route) => route.path !== '*');
return getAllFinalRoutes(routesWithout404); return flattenRoutes(routesWithout404);
} }
function getBrokenLinks({ function getBrokenLinks({

View file

@ -9,7 +9,7 @@ exports[`loadRoutes loads flat route config 1`] = `
"metadata---blog-0-b-6-74c": "blog-2018-12-14-happy-first-birthday-slash-d2c.json", "metadata---blog-0-b-6-74c": "blog-2018-12-14-happy-first-birthday-slash-d2c.json",
}, },
"routesChunkNames": { "routesChunkNames": {
"/blog-599": { "/blog-030": {
"__comp": "__comp---theme-blog-list-pagea-6-a-7ba", "__comp": "__comp---theme-blog-list-pagea-6-a-7ba",
"items": [ "items": [
{ {
@ -31,7 +31,7 @@ import ComponentCreator from '@docusaurus/ComponentCreator';
export default [ export default [
{ {
path: '/blog', path: '/blog',
component: ComponentCreator('/blog', '599'), component: ComponentCreator('/blog', '030'),
exact: true exact: true
}, },
{ {
@ -56,7 +56,7 @@ exports[`loadRoutes loads nested route config 1`] = `
"plugin---docs-hello-665-3ca": "pluginRouteContextModule-100.json", "plugin---docs-hello-665-3ca": "pluginRouteContextModule-100.json",
}, },
"routesChunkNames": { "routesChunkNames": {
"/docs/hello-fcc": { "/docs/hello-ab4": {
"__comp": "__comp---theme-doc-item-178-a40", "__comp": "__comp---theme-doc-item-178-a40",
"__context": { "__context": {
"plugin": "plugin---docs-hello-665-3ca", "plugin": "plugin---docs-hello-665-3ca",
@ -64,11 +64,11 @@ exports[`loadRoutes loads nested route config 1`] = `
"content": "content---docs-helloaff-811", "content": "content---docs-helloaff-811",
"metadata": "metadata---docs-hello-956-741", "metadata": "metadata---docs-hello-956-741",
}, },
"/docs:route-9d0": { "/docs:route-001": {
"__comp": "__comp---theme-doc-roota-94-67a", "__comp": "__comp---theme-doc-roota-94-67a",
"docsMetadata": "docsMetadata---docs-routef-34-881", "docsMetadata": "docsMetadata---docs-routef-34-881",
}, },
"docs/foo/baz-eb2": { "docs/foo/baz-125": {
"__comp": "__comp---theme-doc-item-178-a40", "__comp": "__comp---theme-doc-item-178-a40",
"__context": { "__context": {
"plugin": "plugin---docs-hello-665-3ca", "plugin": "plugin---docs-hello-665-3ca",
@ -83,17 +83,17 @@ import ComponentCreator from '@docusaurus/ComponentCreator';
export default [ export default [
{ {
path: '/docs:route', path: '/docs:route',
component: ComponentCreator('/docs:route', '9d0'), component: ComponentCreator('/docs:route', '001'),
routes: [ routes: [
{ {
path: '/docs/hello', path: '/docs/hello',
component: ComponentCreator('/docs/hello', 'fcc'), component: ComponentCreator('/docs/hello', 'ab4'),
exact: true, exact: true,
sidebar: "main" sidebar: "main"
}, },
{ {
path: 'docs/foo/baz', path: 'docs/foo/baz',
component: ComponentCreator('docs/foo/baz', 'eb2'), component: ComponentCreator('docs/foo/baz', '125'),
sidebar: "secondary", sidebar: "secondary",
"key:a": "containing colon", "key:a": "containing colon",
"key'b": "containing quote", "key'b": "containing quote",

View file

@ -108,6 +108,10 @@ describe('loadRoutes', () => {
content: 'docs/hello.md', content: 'docs/hello.md',
metadata: 'docs-hello-da2.json', metadata: 'docs-hello-da2.json',
}, },
metadata: {
sourceFilePath: 'docs/hello.md',
lastUpdatedAt: 1710842708527,
},
context: { context: {
plugin: 'pluginRouteContextModule-100.json', plugin: 'pluginRouteContextModule-100.json',
}, },
@ -120,6 +124,10 @@ describe('loadRoutes', () => {
content: 'docs/foo/baz.md', content: 'docs/foo/baz.md',
metadata: 'docs-foo-baz-dd9.json', metadata: 'docs-foo-baz-dd9.json',
}, },
metadata: {
sourceFilePath: 'docs/foo/baz.md',
lastUpdatedAt: 1710842708527,
},
context: { context: {
plugin: 'pluginRouteContextModule-100.json', plugin: 'pluginRouteContextModule-100.json',
}, },
@ -142,6 +150,9 @@ describe('loadRoutes', () => {
path: '/blog', path: '/blog',
component: '@theme/BlogListPage', component: '@theme/BlogListPage',
exact: true, exact: true,
metadata: {
lastUpdatedAt: 1710842708527,
},
modules: { modules: {
items: [ items: [
{ {

View file

@ -200,6 +200,7 @@ function genRouteCode(routeConfig: RouteConfig, res: RoutesCode): string {
routes: subroutes, routes: subroutes,
priority, priority,
exact, exact,
metadata,
...props ...props
} = routeConfig; } = routeConfig;

View file

@ -18,10 +18,10 @@ import type {
RouteConfig, RouteConfig,
AllContent, AllContent,
GlobalData, GlobalData,
PluginIdentifier,
LoadedPlugin, LoadedPlugin,
InitializedPlugin, InitializedPlugin,
} from '@docusaurus/types'; } from '@docusaurus/types';
import type {PluginIdentifier} from '@docusaurus/types/src/plugin';
async function translatePlugin({ async function translatePlugin({
plugin, plugin,

View file

@ -6,17 +6,9 @@
*/ */
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
import {normalizeUrl} from '@docusaurus/utils'; import {normalizeUrl, flattenRoutes} from '@docusaurus/utils';
import type {RouteConfig, ReportingSeverity} from '@docusaurus/types'; import type {RouteConfig, ReportingSeverity} from '@docusaurus/types';
// Recursively get the final routes (routes with no subroutes)
export function getAllFinalRoutes(routeConfig: RouteConfig[]): RouteConfig[] {
function getFinalRoutes(route: RouteConfig): RouteConfig[] {
return route.routes ? route.routes.flatMap(getFinalRoutes) : [route];
}
return routeConfig.flatMap(getFinalRoutes);
}
export function handleDuplicateRoutes( export function handleDuplicateRoutes(
routes: RouteConfig[], routes: RouteConfig[],
onDuplicateRoutes: ReportingSeverity, onDuplicateRoutes: ReportingSeverity,
@ -24,7 +16,7 @@ export function handleDuplicateRoutes(
if (onDuplicateRoutes === 'ignore') { if (onDuplicateRoutes === 'ignore') {
return; return;
} }
const allRoutes: string[] = getAllFinalRoutes(routes).map( const allRoutes: string[] = flattenRoutes(routes).map(
(routeConfig) => routeConfig.path, (routeConfig) => routeConfig.path,
); );
const seenRoutes = new Set<string>(); const seenRoutes = new Set<string>();
@ -52,6 +44,13 @@ This could lead to non-deterministic routing behavior.`;
* This is rendered through the catch-all ComponentCreator("*") route * This is rendered through the catch-all ComponentCreator("*") route
* Note CDNs only understand the 404.html file by convention * Note CDNs only understand the 404.html file by convention
* The extension probably permits to avoid emitting "/404/index.html" * The extension probably permits to avoid emitting "/404/index.html"
*
* TODO we should probably deprecate/remove "postBuild({routesPaths})
* The 404 generation handling can be moved to the SSG code
* We only need getAllFinalRoutes() utils IMHO
* This would be a plugin lifecycle breaking change :/
* Although not many plugins probably use this
*
*/ */
const NotFoundRoutePath = '/404.html'; const NotFoundRoutePath = '/404.html';
@ -61,6 +60,6 @@ export function getRoutesPaths(
): string[] { ): string[] {
return [ return [
normalizeUrl([baseUrl, NotFoundRoutePath]), normalizeUrl([baseUrl, NotFoundRoutePath]),
...getAllFinalRoutes(routeConfigs).map((r) => r.path), ...flattenRoutes(routeConfigs).map((r) => r.path),
]; ];
} }

View file

@ -31,8 +31,8 @@ import type {
GlobalData, GlobalData,
LoadContext, LoadContext,
Props, Props,
PluginIdentifier,
} from '@docusaurus/types'; } from '@docusaurus/types';
import type {PluginIdentifier} from '@docusaurus/types/src/plugin';
export type LoadContextParams = { export type LoadContextParams = {
/** Usually the CWD; can be overridden with command argument. */ /** Usually the CWD; can be overridden with command argument. */

View file

@ -42,7 +42,6 @@ cdabcdab
cdpath cdpath
Cena Cena
cena cena
Changefreq
changefreq changefreq
Chedeau Chedeau
chedeau chedeau
@ -157,6 +156,8 @@ Knapen
Koyeb Koyeb
Koyeb's Koyeb's
Lamana Lamana
Lastmod
lastmod
Lifecycles Lifecycles
lifecycles lifecycles
Linkify Linkify

View file

@ -43,17 +43,85 @@ The data that was loaded in `loadContent` will be consumed in `contentLoaded`. I
Create a route to add to the website. Create a route to add to the website.
```ts ```ts
type RouteConfig = { export type RouteConfig = {
/**
* With leading slash. Trailing slash will be normalized by config.
*/
path: string; path: string;
/**
* Component used to render this route, a path that the bundler can `require`.
*/
component: string; component: string;
/**
* Props. Each entry should be `[propName]: pathToPropModule` (created with
* `createData`)
*/
modules?: RouteModules; modules?: RouteModules;
/**
* The route context will wrap the `component`. Use `useRouteContext` to
* retrieve what's declared here. Note that all custom route context declared
* here will be namespaced under {@link RouteContext.data}.
*/
context?: RouteModules;
/**
* Nested routes config, useful for "layout routes" having subroutes.
*/
routes?: RouteConfig[]; routes?: RouteConfig[];
/**
* React router config option: `exact` routes would not match subroutes.
*/
exact?: boolean; exact?: boolean;
/**
* React router config option: `strict` routes are sensitive to the presence
* of a trailing slash.
*/
strict?: boolean;
/**
* Used to sort routes.
* Higher-priority routes will be matched first.
*/
priority?: number; priority?: number;
/**
* Optional route metadata
*/
metadata?: RouteMetadata;
/**
* Extra props; will be available on the client side.
*/
[propName: string]: unknown;
}; };
/**
* Plugin authors can assign extra metadata to the created routes
* It is only available on the Node.js side, and not sent to the browser
* Optional: plugin authors are encouraged but not required to provide it
*
* Some plugins might use this data to provide additional features.
* This is the case of the sitemap plugin to provide support for "lastmod".
* See also: https://github.com/facebook/docusaurus/pull/9954
*/
export type RouteMetadata = {
/**
* The source code file path that led to the creation of the current route
* In official content plugins, this is usually a Markdown or React file
* This path is expected to be relative to the site directory
*/
sourceFilePath?: string;
/**
* The last updated date of this route
* This is generally read from the Git history of the sourceFilePath
* but can also be provided through other means (usually front matter)
*
* This has notably been introduced for adding "lastmod" support to the
* sitemap plugin, see https://github.com/facebook/docusaurus/pull/9954
*/
lastUpdatedAt?: number;
};
type RouteModules = { type RouteModules = {
[module: string]: Module | RouteModules | RouteModules[]; [module: string]: Module | RouteModules | RouteModules[];
}; };
type Module = type Module =
| { | {
path: string; path: string;

View file

@ -39,8 +39,9 @@ Accepted fields:
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `changefreq` | `string` | `'weekly'` | See [sitemap docs](https://www.sitemaps.org/protocol.html#xmlTagDefinitions) | | `lastmod` | `'date' \| 'datetime' \| null` | `null` | `date` is YYYY-MM-DD. `datetime` is a ISO 8601 datetime. `null` is disabled. See [sitemap docs](https://www.sitemaps.org/protocol.html#xmlTagDefinitions). |
| `priority` | `number` | `0.5` | See [sitemap docs](https://www.sitemaps.org/protocol.html#xmlTagDefinitions) | | `changefreq` | `string \| null` | `'weekly'` | 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. |
@ -57,6 +58,14 @@ This plugin also respects some site config:
::: :::
:::note About `lastmod`
The `lastmod` option will only output a sitemap `<lastmod>` tag if plugins provide [route metadata](../plugin-methods/lifecycle-apis.mdx#addRoute) attributes `sourceFilePath` and/or `lastUpdatedAt`.
All the official content plugins provide the metadata for routes backed by a content file (Markdown, MDX or React page components), but it is possible third-party plugin authors do not provide this information, and the plugin will not be able to output a `<lastmod>` tag for their routes.
:::
### Example configuration {#ex-config} ### Example configuration {#ex-config}
You can configure this plugin through preset options or plugin options. You can configure this plugin through preset options or plugin options.
@ -72,6 +81,7 @@ Most Docusaurus users configure this plugin through the preset options.
// Plugin Options: @docusaurus/plugin-sitemap // Plugin Options: @docusaurus/plugin-sitemap
const config = { const config = {
lastmod: 'date',
changefreq: 'weekly', changefreq: 'weekly',
priority: 0.5, priority: 0.5,
ignorePatterns: ['/tags/**'], ignorePatterns: ['/tags/**'],

View file

@ -480,6 +480,9 @@ export default async function createConfigAsync() {
sitemap: { sitemap: {
// Note: /tests/docs already has noIndex: true // Note: /tests/docs already has noIndex: true
ignorePatterns: ['/tests/{blog,pages}/**'], ignorePatterns: ['/tests/{blog,pages}/**'],
lastmod: 'date',
priority: null,
changefreq: null,
}, },
} satisfies Preset.Options, } satisfies Preset.Options,
], ],