diff --git a/.eslintrc.js b/.eslintrc.js index 90f7db3d0c..3b67b00309 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -85,6 +85,7 @@ module.exports = { ignorePattern: '(eslint-disable|@)', }, ], + 'arrow-body-style': OFF, 'no-await-in-loop': OFF, 'no-case-declarations': WARNING, 'no-console': OFF, @@ -347,10 +348,7 @@ module.exports = { ERROR, {'ts-expect-error': 'allow-with-description'}, ], - '@typescript-eslint/consistent-indexed-object-style': [ - WARNING, - 'index-signature', - ], + '@typescript-eslint/consistent-indexed-object-style': OFF, '@typescript-eslint/consistent-type-imports': [ WARNING, {disallowTypeAnnotations: false}, diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts index 2d10609d40..3244150dc2 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts @@ -11,8 +11,7 @@ import {normalizePluginOptions} from '@docusaurus/utils-validation'; import { posixPath, getFileCommitDate, - GIT_FALLBACK_LAST_UPDATE_DATE, - GIT_FALLBACK_LAST_UPDATE_AUTHOR, + LAST_UPDATE_FALLBACK, } from '@docusaurus/utils'; import pluginContentBlog from '../index'; import {validateOptions} from '../options'; @@ -554,14 +553,14 @@ describe('last update', () => { expect(blogPosts[0]?.metadata.lastUpdatedBy).toBe('seb'); expect(blogPosts[0]?.metadata.lastUpdatedAt).toBe( - GIT_FALLBACK_LAST_UPDATE_DATE, + LAST_UPDATE_FALLBACK.lastUpdatedAt, ); expect(blogPosts[1]?.metadata.lastUpdatedBy).toBe( - GIT_FALLBACK_LAST_UPDATE_AUTHOR, + LAST_UPDATE_FALLBACK.lastUpdatedBy, ); expect(blogPosts[1]?.metadata.lastUpdatedAt).toBe( - GIT_FALLBACK_LAST_UPDATE_DATE, + LAST_UPDATE_FALLBACK.lastUpdatedAt, ); expect(blogPosts[2]?.metadata.lastUpdatedBy).toBe('seb'); @@ -570,7 +569,7 @@ describe('last update', () => { ); expect(blogPosts[3]?.metadata.lastUpdatedBy).toBe( - GIT_FALLBACK_LAST_UPDATE_AUTHOR, + LAST_UPDATE_FALLBACK.lastUpdatedBy, ); expect(blogPosts[3]?.metadata.lastUpdatedAt).toBe( lastUpdateFor('2021-01-01'), @@ -591,13 +590,13 @@ describe('last update', () => { expect(blogPosts[0]?.metadata.title).toBe('Author'); expect(blogPosts[0]?.metadata.lastUpdatedBy).toBeUndefined(); 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.lastUpdatedBy).toBeUndefined(); expect(blogPosts[1]?.metadata.lastUpdatedAt).toBe( - GIT_FALLBACK_LAST_UPDATE_DATE, + LAST_UPDATE_FALLBACK.lastUpdatedAt, ); expect(blogPosts[2]?.metadata.title).toBe('Both'); @@ -628,7 +627,7 @@ describe('last update', () => { expect(blogPosts[0]?.metadata.lastUpdatedAt).toBeUndefined(); expect(blogPosts[1]?.metadata.lastUpdatedBy).toBe( - GIT_FALLBACK_LAST_UPDATE_AUTHOR, + LAST_UPDATE_FALLBACK.lastUpdatedBy, ); expect(blogPosts[1]?.metadata.lastUpdatedAt).toBeUndefined(); @@ -636,7 +635,7 @@ describe('last update', () => { expect(blogPosts[2]?.metadata.lastUpdatedAt).toBeUndefined(); expect(blogPosts[3]?.metadata.lastUpdatedBy).toBe( - GIT_FALLBACK_LAST_UPDATE_AUTHOR, + LAST_UPDATE_FALLBACK.lastUpdatedBy, ); expect(blogPosts[3]?.metadata.lastUpdatedAt).toBeUndefined(); }); diff --git a/packages/docusaurus-plugin-content-blog/src/index.ts b/packages/docusaurus-plugin-content-blog/src/index.ts index eba9148777..dfc1f097f4 100644 --- a/packages/docusaurus-plugin-content-blog/src/index.ts +++ b/packages/docusaurus-plugin-content-blog/src/index.ts @@ -11,6 +11,7 @@ import { normalizeUrl, docuHash, aliasedSitePath, + aliasedSitePathToRelativePath, getPluginI18nPath, posixPath, addTrailingPathSeparator, @@ -33,7 +34,12 @@ import {createBlogFeedFiles} from './feed'; import {toTagProp, toTagsProp} from './props'; 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 { PluginOptions, BlogPostFrontMatter, @@ -273,6 +279,15 @@ export default async function pluginContentBlog( JSON.stringify(blogMetadata, null, 2), ); + function createBlogPostRouteMetadata( + blogPostMeta: BlogPostMetadata, + ): RouteMetadata { + return { + sourceFilePath: aliasedSitePathToRelativePath(blogPostMeta.source), + lastUpdatedAt: blogPostMeta.lastUpdatedAt, + }; + } + // Create routes for blog entries. await Promise.all( blogPosts.map(async (blogPost) => { @@ -292,6 +307,7 @@ export default async function pluginContentBlog( sidebar: aliasedSource(sidebarProp), content: metadata.source, }, + metadata: createBlogPostRouteMetadata(metadata), context: { blogMetadata: aliasedSource(blogMetadataPath), }, diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap index 2cacda09b9..9c88d8e20b 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap @@ -1482,6 +1482,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/hello.md", + }, "modules": { "content": "@site/docs/hello.md", }, @@ -1491,6 +1495,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/slugs/absoluteSlug.md", + }, "modules": { "content": "@site/docs/slugs/absoluteSlug.md", }, @@ -1508,6 +1516,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/customLastUpdate.md", + }, "modules": { "content": "@site/docs/customLastUpdate.md", }, @@ -1516,6 +1528,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/doc with space.md", + }, "modules": { "content": "@site/docs/doc with space.md", }, @@ -1524,6 +1540,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/doc-draft.md", + }, "modules": { "content": "@site/docs/doc-draft.md", }, @@ -1532,6 +1552,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/doc-unlisted.md", + }, "modules": { "content": "@site/docs/doc-unlisted.md", }, @@ -1541,6 +1565,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/foo/bar.md", + }, "modules": { "content": "@site/docs/foo/bar.md", }, @@ -1550,6 +1578,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/foo/baz.md", + }, "modules": { "content": "@site/docs/foo/baz.md", }, @@ -1559,6 +1591,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/headingAsTitle.md", + }, "modules": { "content": "@site/docs/headingAsTitle.md", }, @@ -1568,6 +1604,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/rootResolvedSlug.md", + }, "modules": { "content": "@site/docs/rootResolvedSlug.md", }, @@ -1577,6 +1617,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/ipsum.md", + }, "modules": { "content": "@site/docs/ipsum.md", }, @@ -1585,6 +1629,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/lastUpdateAuthorOnly.md", + }, "modules": { "content": "@site/docs/lastUpdateAuthorOnly.md", }, @@ -1593,6 +1641,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/lastUpdateDateOnly.md", + }, "modules": { "content": "@site/docs/lastUpdateDateOnly.md", }, @@ -1601,6 +1653,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/lorem.md", + }, "modules": { "content": "@site/docs/lorem.md", }, @@ -1609,6 +1665,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/rootAbsoluteSlug.md", + }, "modules": { "content": "@site/docs/rootAbsoluteSlug.md", }, @@ -1618,6 +1678,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/rootRelativeSlug.md", + }, "modules": { "content": "@site/docs/rootRelativeSlug.md", }, @@ -1627,6 +1691,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/rootTryToEscapeSlug.md", + }, "modules": { "content": "@site/docs/rootTryToEscapeSlug.md", }, @@ -1636,6 +1704,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/slugs/resolvedSlug.md", + }, "modules": { "content": "@site/docs/slugs/resolvedSlug.md", }, @@ -1644,6 +1716,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/slugs/relativeSlug.md", + }, "modules": { "content": "@site/docs/slugs/relativeSlug.md", }, @@ -1652,6 +1728,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/slugs/tryToEscapeSlug.md", + }, "modules": { "content": "@site/docs/slugs/tryToEscapeSlug.md", }, @@ -1660,6 +1740,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/unlisted-category/index.md", + }, "modules": { "content": "@site/docs/unlisted-category/index.md", }, @@ -1669,6 +1753,10 @@ exports[`simple website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/unlisted-category/unlisted-category-doc.md", + }, "modules": { "content": "@site/docs/unlisted-category/unlisted-category-doc.md", }, @@ -2940,6 +3028,10 @@ exports[`versioned website (community) content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "i18n/en/docusaurus-plugin-content-docs-community/current/team.md", + }, "modules": { "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", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "community_versioned_docs/version-1.0.0/team.md", + }, "modules": { "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", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md", + }, "modules": { "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", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-1.0.0/foo/bar.md", + }, "modules": { "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", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-1.0.0/foo/baz.md", + }, "modules": { "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", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/hello.md", + }, "modules": { "content": "@site/docs/hello.md", }, @@ -4260,6 +4372,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/slugs/absoluteSlug.md", + }, "modules": { "content": "@site/docs/slugs/absoluteSlug.md", }, @@ -4268,6 +4384,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/foo/bar.md", + }, "modules": { "content": "@site/docs/foo/bar.md", }, @@ -4277,6 +4397,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/slugs/resolvedSlug.md", + }, "modules": { "content": "@site/docs/slugs/resolvedSlug.md", }, @@ -4285,6 +4409,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/slugs/relativeSlug.md", + }, "modules": { "content": "@site/docs/slugs/relativeSlug.md", }, @@ -4293,6 +4421,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "docs/slugs/tryToEscapeSlug.md", + }, "modules": { "content": "@site/docs/slugs/tryToEscapeSlug.md", }, @@ -4319,6 +4451,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-withSlugs/slugs/absoluteSlug.md", + }, "modules": { "content": "@site/versioned_docs/version-withSlugs/slugs/absoluteSlug.md", }, @@ -4327,6 +4463,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-withSlugs/rootResolvedSlug.md", + }, "modules": { "content": "@site/versioned_docs/version-withSlugs/rootResolvedSlug.md", }, @@ -4335,6 +4475,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-withSlugs/rootAbsoluteSlug.md", + }, "modules": { "content": "@site/versioned_docs/version-withSlugs/rootAbsoluteSlug.md", }, @@ -4344,6 +4488,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-withSlugs/rootRelativeSlug.md", + }, "modules": { "content": "@site/versioned_docs/version-withSlugs/rootRelativeSlug.md", }, @@ -4352,6 +4500,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-withSlugs/rootTryToEscapeSlug.md", + }, "modules": { "content": "@site/versioned_docs/version-withSlugs/rootTryToEscapeSlug.md", }, @@ -4360,6 +4512,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-withSlugs/slugs/resolvedSlug.md", + }, "modules": { "content": "@site/versioned_docs/version-withSlugs/slugs/resolvedSlug.md", }, @@ -4368,6 +4524,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-withSlugs/slugs/relativeSlug.md", + }, "modules": { "content": "@site/versioned_docs/version-withSlugs/slugs/relativeSlug.md", }, @@ -4376,6 +4536,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-withSlugs/slugs/tryToEscapeSlug.md", + }, "modules": { "content": "@site/versioned_docs/version-withSlugs/slugs/tryToEscapeSlug.md", }, @@ -4402,6 +4566,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-1.0.1/hello.md", + }, "modules": { "content": "@site/versioned_docs/version-1.0.1/hello.md", }, @@ -4411,6 +4579,10 @@ exports[`versioned website content: route config 1`] = ` { "component": "@theme/DocItem", "exact": true, + "metadata": { + "lastUpdatedAt": undefined, + "sourceFilePath": "versioned_docs/version-1.0.1/foo/bar.md", + }, "modules": { "content": "@site/versioned_docs/version-1.0.1/foo/bar.md", }, diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/docs.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/docs.test.ts index d1e343bcd4..ef2b03de79 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/docs.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/docs.test.ts @@ -12,7 +12,7 @@ import { createSlugger, posixPath, DEFAULT_PLUGIN_ID, - GIT_FALLBACK_LAST_UPDATE_DATE, + LAST_UPDATE_FALLBACK, } from '@docusaurus/utils'; import {createSidebarsUtils} from '../sidebars/utils'; import { @@ -479,8 +479,8 @@ describe('simple site', () => { custom_edit_url: 'https://github.com/customUrl/docs/lorem.md', unrelated_front_matter: "won't be part of metadata", }, - lastUpdatedAt: GIT_FALLBACK_LAST_UPDATE_DATE, - lastUpdatedBy: 'Author', + lastUpdatedAt: LAST_UPDATE_FALLBACK.lastUpdatedAt, + lastUpdatedBy: LAST_UPDATE_FALLBACK.lastUpdatedBy, tags: [], unlisted: false, }); @@ -614,7 +614,7 @@ describe('simple site', () => { }, title: 'Last Update Author Only', }, - lastUpdatedAt: GIT_FALLBACK_LAST_UPDATE_DATE, + lastUpdatedAt: LAST_UPDATE_FALLBACK.lastUpdatedAt, lastUpdatedBy: 'Custom Author (processed by parseFrontMatter)', sidebarPosition: undefined, tags: [], diff --git a/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts b/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts index 38606e5116..bae47646fb 100644 --- a/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts +++ b/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts @@ -17,6 +17,7 @@ declare module '@docusaurus/plugin-content-docs' { TagModule, Tag, FrontMatterLastUpdate, + LastUpdateData, } from '@docusaurus/utils'; import type {Plugin, LoadContext} from '@docusaurus/types'; import type {Overwrite, Required} from 'utility-types'; @@ -397,13 +398,6 @@ declare module '@docusaurus/plugin-content-docs' { 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 & { /** * The document id. diff --git a/packages/docusaurus-plugin-content-docs/src/routes.ts b/packages/docusaurus-plugin-content-docs/src/routes.ts index 0b7a642559..cff90fc491 100644 --- a/packages/docusaurus-plugin-content-docs/src/routes.ts +++ b/packages/docusaurus-plugin-content-docs/src/routes.ts @@ -7,21 +7,38 @@ import _ from 'lodash'; import logger from '@docusaurus/logger'; -import {docuHash, createSlugger, normalizeUrl} from '@docusaurus/utils'; +import { + docuHash, + createSlugger, + normalizeUrl, + aliasedSitePathToRelativePath, +} from '@docusaurus/utils'; import { toTagDocListProp, toTagsListTagsProp, toVersionMetadataProp, } from './props'; 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 { CategoryGeneratedIndexMetadata, + DocMetadata, PluginOptions, PropTagsListPage, } from '@docusaurus/plugin-content-docs'; +function createDocRouteMetadata(docMeta: DocMetadata): RouteMetadata { + return { + sourceFilePath: aliasedSitePathToRelativePath(docMeta.source), + lastUpdatedAt: docMeta.lastUpdatedAt, + }; +} + async function buildVersionCategoryGeneratedIndexRoutes({ version, actions, @@ -68,26 +85,27 @@ async function buildVersionDocRoutes({ options, }: BuildVersionRoutesParam): Promise { return Promise.all( - version.docs.map(async (metadataItem) => { + version.docs.map(async (doc) => { await actions.createData( // Note that this created data path must be in sync with // metadataPath provided to mdx-loader. - `${docuHash(metadataItem.source)}.json`, - JSON.stringify(metadataItem, null, 2), + `${docuHash(doc.source)}.json`, + JSON.stringify(doc, null, 2), ); const docRoute: RouteConfig = { - path: metadataItem.permalink, + path: doc.permalink, component: options.docItemComponent, exact: true, modules: { - content: metadataItem.source, + content: doc.source, }, + metadata: createDocRouteMetadata(doc), // Because the parent (DocRoot) comp need to access it easily // This permits to render the sidebar once without unmount/remount when // navigating (and preserve sidebar state) - ...(metadataItem.sidebar && { - sidebar: metadataItem.sidebar, + ...(doc.sidebar && { + sidebar: doc.sidebar, }), }; diff --git a/packages/docusaurus-plugin-content-pages/src/index.ts b/packages/docusaurus-plugin-content-pages/src/index.ts index a4707110f2..7fe81e83af 100644 --- a/packages/docusaurus-plugin-content-pages/src/index.ts +++ b/packages/docusaurus-plugin-content-pages/src/index.ts @@ -11,6 +11,7 @@ import { encodePath, fileToPath, aliasedSitePath, + aliasedSitePathToRelativePath, docuHash, getPluginI18nPath, getFolderContainingFile, @@ -24,8 +25,7 @@ import { isDraft, } from '@docusaurus/utils'; import {validatePageFrontMatter} from './frontMatter'; - -import type {LoadContext, Plugin} from '@docusaurus/types'; +import type {LoadContext, Plugin, RouteMetadata} from '@docusaurus/types'; import type {PagesContentPaths} from './types'; import type { PluginOptions, @@ -159,9 +159,20 @@ export default function pluginContentPages( 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( content.map(async (metadata) => { const {permalink, source} = metadata; + const routeMetadata = createPageRouteMetadata(metadata); if (metadata.type === 'mdx') { await createData( // Note that this created data path must be in sync with @@ -173,6 +184,7 @@ export default function pluginContentPages( path: permalink, component: options.mdxPageComponent, exact: true, + metadata: routeMetadata, modules: { content: source, }, @@ -182,6 +194,7 @@ export default function pluginContentPages( path: permalink, component: source, exact: true, + metadata: routeMetadata, modules: { config: `@generated/docusaurus.config`, }, diff --git a/packages/docusaurus-plugin-sitemap/package.json b/packages/docusaurus-plugin-sitemap/package.json index d8e93984ed..0441105eb9 100644 --- a/packages/docusaurus-plugin-sitemap/package.json +++ b/packages/docusaurus-plugin-sitemap/package.json @@ -28,6 +28,9 @@ "sitemap": "^7.1.1", "tslib": "^2.6.0" }, + "devDependencies": { + "@total-typescript/shoehorn": "^0.1.2" + }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" diff --git a/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemap.test.ts b/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemap.test.ts index 8a4fa2ac55..c080c8b028 100644 --- a/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemap.test.ts +++ b/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemap.test.ts @@ -6,95 +6,91 @@ */ import React from 'react'; -import {EnumChangefreq} from 'sitemap'; +import {fromPartial} from '@total-typescript/shoehorn'; import createSitemap from '../createSitemap'; 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', () => { it('simple site', async () => { - const sitemap = await createSitemap( - { - url: 'https://example.com', - } as DocusaurusConfig, - ['/', '/test'], - {}, - { - changefreq: EnumChangefreq.DAILY, - priority: 0.7, - ignorePatterns: [], - filename: 'sitemap.xml', - }, - ); + const sitemap = await createSitemap({ + siteConfig, + routes: routes(['/', '/test']), + head: {}, + options, + }); expect(sitemap).toContain( ``, ); }); - it('empty site', () => - expect(async () => { - // @ts-expect-error: test - await createSitemap({}, [], {}, {} as PluginOptions); - }).rejects.toThrow( - 'URL in docusaurus.config.js cannot be empty/undefined.', - )); - - 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('site with no routes', async () => { + const sitemap = await createSitemap({ + siteConfig, + routes: routes([]), + head: {}, + options, + }); + expect(sitemap).toBeNull(); }); it('excludes patterns configured to be ignored', async () => { - const sitemap = await createSitemap( - { - url: 'https://example.com', - } as DocusaurusConfig, - ['/', '/search/', '/tags/', '/search/foo', '/tags/foo/bar'], - {}, - { - changefreq: EnumChangefreq.DAILY, - priority: 0.7, + const sitemap = await createSitemap({ + siteConfig, + routes: routes([ + '/', + '/search/', + '/tags/', + '/search/foo', + '/tags/foo/bar', + ]), + head: {}, + options: { + ...options, ignorePatterns: [ // Shallow ignore '/search/', // Deep ignore '/tags/**', ], - filename: 'sitemap.xml', }, - ); + }); + expect(sitemap).not.toContain('/search/'); expect(sitemap).toContain('/search/foo'); expect(sitemap).not.toContain('/tags'); }); it('keep trailing slash unchanged', async () => { - const sitemap = await createSitemap( - { - url: 'https://example.com', - trailingSlash: undefined, - } as DocusaurusConfig, - ['/', '/test', '/nested/test', '/nested/test2/'], - {}, - { - changefreq: EnumChangefreq.DAILY, - priority: 0.7, - ignorePatterns: [], - filename: 'sitemap.xml', - }, - ); + const sitemap = await createSitemap({ + siteConfig, + routes: routes(['/', '/test', '/nested/test', '/nested/test2/']), + head: {}, + options, + }); expect(sitemap).toContain('https://example.com/'); expect(sitemap).toContain('https://example.com/test'); @@ -103,20 +99,12 @@ describe('createSitemap', () => { }); it('add trailing slash', async () => { - const sitemap = await createSitemap( - { - url: 'https://example.com', - trailingSlash: true, - } as DocusaurusConfig, - ['/', '/test', '/nested/test', '/nested/test2/'], - {}, - { - changefreq: EnumChangefreq.DAILY, - priority: 0.7, - ignorePatterns: [], - filename: 'sitemap.xml', - }, - ); + const sitemap = await createSitemap({ + siteConfig: {...siteConfig, trailingSlash: true}, + routes: routes(['/', '/test', '/nested/test', '/nested/test2/']), + head: {}, + options, + }); expect(sitemap).toContain('https://example.com/'); expect(sitemap).toContain('https://example.com/test/'); @@ -125,20 +113,16 @@ describe('createSitemap', () => { }); it('remove trailing slash', async () => { - const sitemap = await createSitemap( - { + const sitemap = await createSitemap({ + siteConfig: { + ...siteConfig, url: 'https://example.com', 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('https://example.com/'); expect(sitemap).toContain('https://example.com/test'); @@ -147,13 +131,11 @@ describe('createSitemap', () => { }); it('filters pages with noindex', async () => { - const sitemap = await createSitemap( - { - url: 'https://example.com', - trailingSlash: false, - } as DocusaurusConfig, - ['/', '/noindex', '/nested/test', '/nested/test2/'], - { + const sitemap = await createSitemap({ + siteConfig, + routesPaths: ['/', '/noindex', '/nested/test', '/nested/test2/'], + routes: routes(['/', '/noindex', '/nested/test', '/nested/test2/']), + head: { '/noindex': { meta: { // @ts-expect-error: bad lib def @@ -166,24 +148,18 @@ describe('createSitemap', () => { }, }, }, - { - changefreq: EnumChangefreq.DAILY, - priority: 0.7, - ignorePatterns: [], - }, - ); + options, + }); expect(sitemap).not.toContain('/noindex'); }); it('does not generate anything for all pages with noindex', async () => { - const sitemap = await createSitemap( - { - url: 'https://example.com', - trailingSlash: false, - } as DocusaurusConfig, - ['/', '/noindex'], - { + const sitemap = await createSitemap({ + siteConfig, + routesPaths: ['/', '/noindex'], + routes: routes(['/', '/noindex']), + head: { '/': { meta: { // @ts-expect-error: bad lib def @@ -201,12 +177,8 @@ describe('createSitemap', () => { }, }, }, - { - changefreq: EnumChangefreq.DAILY, - priority: 0.7, - ignorePatterns: [], - }, - ); + options, + }); expect(sitemap).toBeNull(); }); diff --git a/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemapItem.test.ts b/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemapItem.test.ts new file mode 100644 index 0000000000..8982334fd2 --- /dev/null +++ b/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemapItem.test.ts @@ -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; + siteConfig?: Partial; + options?: Partial; +}) { + return createSitemapItem({ + route: params.route as unknown as RouteConfig, + siteConfig: {...siteConfig, ...params.siteConfig}, + options: {...DEFAULT_OPTIONS, ...params.options}, + }); +} + +function testRoute(route: Partial) { + 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", + } + `); + }); + }); + }); +}); diff --git a/packages/docusaurus-plugin-sitemap/src/__tests__/options.test.ts b/packages/docusaurus-plugin-sitemap/src/__tests__/options.test.ts index 7754ced825..7b3cac21b3 100644 --- a/packages/docusaurus-plugin-sitemap/src/__tests__/options.test.ts +++ b/packages/docusaurus-plugin-sitemap/src/__tests__/options.test.ts @@ -12,7 +12,6 @@ import { type Options, type PluginOptions, } from '../options'; -import type {EnumChangefreq} from 'sitemap'; import type {Validate} from '@docusaurus/types'; function testValidate(options: Options) { @@ -34,9 +33,10 @@ describe('validateOptions', () => { it('accepts correctly defined user options', () => { const userOptions: Options = { - changefreq: 'yearly' as EnumChangefreq, + changefreq: 'yearly', priority: 0.9, ignorePatterns: ['/search/**'], + lastmod: 'datetime', }; expect(testValidate(userOptions)).toEqual({ ...defaultOptions, @@ -44,32 +44,209 @@ describe('validateOptions', () => { }); }); - it('rejects out-of-range priority inputs', () => { - expect(() => - testValidate({priority: 2}), - ).toThrowErrorMatchingInlineSnapshot( - `""priority" must be less than or equal to 1"`, - ); + describe('lastmod', () => { + it('accepts lastmod undefined', () => { + const userOptions: Options = { + lastmod: undefined, + }; + 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', () => { - expect(() => - testValidate({changefreq: 'annually' as EnumChangefreq}), - ).toThrowErrorMatchingInlineSnapshot( - `""changefreq" must be one of [daily, monthly, always, hourly, weekly, yearly, never]"`, - ); + describe('priority', () => { + it('accepts priority undefined', () => { + const userOptions: Options = { + priority: undefined, + }; + 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', () => { - expect(() => - // @ts-expect-error: test - testValidate({ignorePatterns: '/search'}), - ).toThrowErrorMatchingInlineSnapshot(`""ignorePatterns" must be an array"`); - expect(() => - // @ts-expect-error: test - testValidate({ignorePatterns: [/^\/search/]}), - ).toThrowErrorMatchingInlineSnapshot( - `""ignorePatterns[0]" must be a string"`, - ); + describe('changefreq', () => { + it('accepts changefreq undefined', () => { + const userOptions: Options = { + changefreq: undefined, + }; + expect(testValidate(userOptions)).toEqual(defaultOptions); + }); + + it('accepts changefreq null', () => { + 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"`, + ); + }); }); }); diff --git a/packages/docusaurus-plugin-sitemap/src/__tests__/xml.test.ts b/packages/docusaurus-plugin-sitemap/src/__tests__/xml.test.ts new file mode 100644 index 0000000000..411f768571 --- /dev/null +++ b/packages/docusaurus-plugin-sitemap/src/__tests__/xml.test.ts @@ -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( + `"https://docusaurus.io/docs/doc1"`, + ); + }); + + 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( + `"https://docusaurus.io/docs/doc12024-01-01T00:00:00.000Zalways1.0"`, + ); + }); + + 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( + `"https://docusaurus.io/docs/doc12024-01-01always1.0"`, + ); + }); +}); diff --git a/packages/docusaurus-plugin-sitemap/src/createSitemap.ts b/packages/docusaurus-plugin-sitemap/src/createSitemap.ts index 2c91667397..1f7790db79 100644 --- a/packages/docusaurus-plugin-sitemap/src/createSitemap.ts +++ b/packages/docusaurus-plugin-sitemap/src/createSitemap.ts @@ -6,13 +6,23 @@ */ import type {ReactElement} from 'react'; -import {SitemapStream, streamToPromise} from 'sitemap'; -import {applyTrailingSlash} from '@docusaurus/utils-common'; -import {createMatcher} from '@docusaurus/utils'; -import type {DocusaurusConfig} from '@docusaurus/types'; +import {createMatcher, flattenRoutes} from '@docusaurus/utils'; +import {sitemapItemsToXmlString} from './xml'; +import {createSitemapItem} from './createSitemapItem'; +import type {SitemapItem} from './types'; +import type {DocusaurusConfig, RouteConfig} from '@docusaurus/types'; import type {HelmetServerState} from 'react-helmet-async'; import type {PluginOptions} from './options'; +type CreateSitemapParams = { + siteConfig: DocusaurusConfig; + routes: RouteConfig[]; + head: {[location: string]: HelmetServerState}; + options: PluginOptions; +}; + +// Maybe we want to add a routeConfig.metadata.noIndex instead? +// But using Helmet is more reliable for third-party plugins... function isNoIndexMetaRoute({ head, route, @@ -47,50 +57,51 @@ function isNoIndexMetaRoute({ ); } -export default async function createSitemap( - siteConfig: DocusaurusConfig, - routesPaths: string[], - head: {[location: string]: HelmetServerState}, - options: PluginOptions, -): Promise { - const {url: hostname} = siteConfig; - if (!hostname) { - throw new Error('URL in docusaurus.config.js cannot be empty/undefined.'); - } - const {changefreq, priority, ignorePatterns} = options; +// Not all routes should appear in the sitemap, and we should filter: +// - parent routes, used for layouts +// - routes matching options.ignorePatterns +// - routes with no index metadata +function getSitemapRoutes({routes, head, options}: CreateSitemapParams) { + const {ignorePatterns} = options; const ignoreMatcher = createMatcher(ignorePatterns); - function isRouteExcluded(route: string) { + function isRouteExcluded(route: RouteConfig) { return ( - route.endsWith('404.html') || - ignoreMatcher(route) || - isNoIndexMetaRoute({head, route}) + ignoreMatcher(route.path) || isNoIndexMetaRoute({head, route: route.path}) ); } - const includedRoutes = routesPaths.filter((route) => !isRouteExcluded(route)); + return flattenRoutes(routes).filter((route) => !isRouteExcluded(route)); +} - if (includedRoutes.length === 0) { +async function createSitemapItems( + params: CreateSitemapParams, +): Promise { + 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 { + const items = await createSitemapItems(params); + if (items.length === 0) { return null; } - - const sitemapStream = new SitemapStream({hostname}); - - includedRoutes.forEach((routePath) => - sitemapStream.write({ - url: applyTrailingSlash(routePath, { - trailingSlash: siteConfig.trailingSlash, - baseUrl: siteConfig.baseUrl, - }), - changefreq, - priority, - }), - ); - - sitemapStream.end(); - - const generatedSitemap = (await streamToPromise(sitemapStream)).toString(); - - return generatedSitemap; + const xmlString = await sitemapItemsToXmlString(items, { + lastmod: params.options.lastmod, + }); + return xmlString; } diff --git a/packages/docusaurus-plugin-sitemap/src/createSitemapItem.ts b/packages/docusaurus-plugin-sitemap/src/createSitemapItem.ts new file mode 100644 index 0000000000..83c96cedf0 --- /dev/null +++ b/packages/docusaurus-plugin-sitemap/src/createSitemapItem.ts @@ -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 { + 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 = { + 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 { + 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 { + 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}), + }; +} diff --git a/packages/docusaurus-plugin-sitemap/src/index.ts b/packages/docusaurus-plugin-sitemap/src/index.ts index fc71d65590..e228d1d2d7 100644 --- a/packages/docusaurus-plugin-sitemap/src/index.ts +++ b/packages/docusaurus-plugin-sitemap/src/index.ts @@ -19,17 +19,17 @@ export default function pluginSitemap( return { name: 'docusaurus-plugin-sitemap', - async postBuild({siteConfig, routesPaths, outDir, head}) { + async postBuild({siteConfig, routes, outDir, head}) { if (siteConfig.noIndex) { return; } // Generate sitemap. - const generatedSitemap = await createSitemap( + const generatedSitemap = await createSitemap({ siteConfig, - routesPaths, + routes, head, options, - ); + }); if (!generatedSitemap) { return; } diff --git a/packages/docusaurus-plugin-sitemap/src/options.ts b/packages/docusaurus-plugin-sitemap/src/options.ts index 9cc1fe1b8c..e6d4c94e9a 100644 --- a/packages/docusaurus-plugin-sitemap/src/options.ts +++ b/packages/docusaurus-plugin-sitemap/src/options.ts @@ -6,33 +6,60 @@ */ import {Joi} from '@docusaurus/utils-validation'; -import {EnumChangefreq} from 'sitemap'; +import {ChangeFreqList, LastModOptionList} from './types'; import type {OptionValidationContext} from '@docusaurus/types'; +import type {ChangeFreq, LastModOption} from './types'; 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. * Useful if you have two plugin instances outputting two files. */ 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; export const DEFAULT_OPTIONS: PluginOptions = { - changefreq: EnumChangefreq.WEEKLY, - priority: 0.5, - ignorePatterns: [], 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({ @@ -41,10 +68,28 @@ const PluginOptionSchema = Joi.object({ 'any.unknown': '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() - .valid(...Object.values(EnumChangefreq)) + .valid(null, ...ChangeFreqList) .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() .items(Joi.string()) .default(DEFAULT_OPTIONS.ignorePatterns), diff --git a/packages/docusaurus-plugin-sitemap/src/types.ts b/packages/docusaurus-plugin-sitemap/src/types.ts new file mode 100644 index 0000000000..f959ca0901 --- /dev/null +++ b/packages/docusaurus-plugin-sitemap/src/types.ts @@ -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; +}; diff --git a/packages/docusaurus-plugin-sitemap/src/xml.ts b/packages/docusaurus-plugin-sitemap/src/xml.ts new file mode 100644 index 0000000000..b2d7c96a95 --- /dev/null +++ b/packages/docusaurus-plugin-sitemap/src/xml.ts @@ -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 { + 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(); +} diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 257ec57811..ecc5810be3 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -45,6 +45,7 @@ export { export { Plugin, + PluginIdentifier, InitializedPlugin, LoadedPlugin, PluginModule, @@ -69,6 +70,7 @@ export { export { RouteConfig, + RouteMetadata, RouteContext, PluginRouteContext, Registry, diff --git a/packages/docusaurus-types/src/routing.d.ts b/packages/docusaurus-types/src/routing.d.ts index aff344d524..b87eee9676 100644 --- a/packages/docusaurus-types/src/routing.d.ts +++ b/packages/docusaurus-types/src/routing.d.ts @@ -11,7 +11,7 @@ import type {ParsedUrlQueryInput} from 'querystring'; * 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. * - * 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. */ export type Module = @@ -36,14 +36,45 @@ export type 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 * `addRoute` action. */ 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; - /** 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; /** * Props. Each entry should be `[propName]: pathToPropModule` (created with @@ -56,18 +87,31 @@ export type RouteConfig = { * here will be namespaced under {@link RouteContext.data}. */ context?: RouteModules; - /** Nested routes config. */ + /** + * Nested routes config, useful for "layout routes" having subroutes. + */ routes?: RouteConfig[]; - /** React router config option: `exact` routes would not match subroutes. */ + /** + * React router config option: `exact` routes would not match subroutes. + */ 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 placed first. */ + /** + * Used to sort routes. + * Higher-priority routes will be matched first. + */ 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; }; diff --git a/packages/docusaurus-utils/src/__tests__/gitUtils.test.ts b/packages/docusaurus-utils/src/__tests__/gitUtils.test.ts index 381d9d8d85..52d0687f2e 100644 --- a/packages/docusaurus-utils/src/__tests__/gitUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/gitUtils.test.ts @@ -9,7 +9,7 @@ import fs from 'fs-extra'; import path from 'path'; import {createTempRepo} from '@testing-utils/git'; import {FileNotTrackedError, getFileCommitDate} from '../gitUtils'; -import {getFileLastUpdate} from '../lastUpdateUtils'; +import {getGitLastUpdate} from '../lastUpdateUtils'; /* eslint-disable no-restricted-properties */ function initializeTempRepo() { @@ -146,8 +146,9 @@ describe('getFileCommitDate', () => { const tempFilePath2 = path.join(repoDir, 'file2.md'); await fs.writeFile(tempFilePath1, 'Lorem ipsum :)'); await fs.writeFile(tempFilePath2, 'Lorem ipsum :)'); - await expect(getFileLastUpdate(tempFilePath1)).resolves.toBeNull(); - await expect(getFileLastUpdate(tempFilePath2)).resolves.toBeNull(); + // TODO this is not the correct place to test "getGitLastUpdate" + await expect(getGitLastUpdate(tempFilePath1)).resolves.toBeNull(); + await expect(getGitLastUpdate(tempFilePath2)).resolves.toBeNull(); expect(consoleMock).toHaveBeenCalledTimes(1); expect(consoleMock).toHaveBeenLastCalledWith( expect.stringMatching(/not tracked by git./), diff --git a/packages/docusaurus-utils/src/__tests__/lastUpdateUtils.test.ts b/packages/docusaurus-utils/src/__tests__/lastUpdateUtils.test.ts index c69772829f..14e907e994 100644 --- a/packages/docusaurus-utils/src/__tests__/lastUpdateUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/lastUpdateUtils.test.ts @@ -11,13 +11,12 @@ import path from 'path'; import {createTempRepo} from '@testing-utils/git'; import shell from 'shelljs'; import { - getFileLastUpdate, - GIT_FALLBACK_LAST_UPDATE_AUTHOR, - GIT_FALLBACK_LAST_UPDATE_DATE, + getGitLastUpdate, + LAST_UPDATE_FALLBACK, readLastUpdateData, } from '@docusaurus/utils'; -describe('getFileLastUpdate', () => { +describe('getGitLastUpdate', () => { const {repoDir} = createTempRepo(); const existingFilePath = path.join( @@ -25,15 +24,15 @@ describe('getFileLastUpdate', () => { '__fixtures__/simple-site/hello.md', ); it('existing test file in repository with Git timestamp', async () => { - const lastUpdateData = await getFileLastUpdate(existingFilePath); + const lastUpdateData = await getGitLastUpdate(existingFilePath); expect(lastUpdateData).not.toBeNull(); - const {author, timestamp} = lastUpdateData!; - expect(author).not.toBeNull(); - expect(typeof author).toBe('string'); + const {lastUpdatedAt, lastUpdatedBy} = lastUpdateData!; + expect(lastUpdatedBy).not.toBeNull(); + expect(typeof lastUpdatedBy).toBe('string'); - expect(timestamp).not.toBeNull(); - expect(typeof timestamp).toBe('number'); + expect(lastUpdatedAt).not.toBeNull(); + expect(typeof lastUpdatedAt).toBe('number'); }); it('existing test file with spaces in path', async () => { @@ -41,15 +40,15 @@ describe('getFileLastUpdate', () => { __dirname, '__fixtures__/simple-site/doc with space.md', ); - const lastUpdateData = await getFileLastUpdate(filePathWithSpace); + const lastUpdateData = await getGitLastUpdate(filePathWithSpace); expect(lastUpdateData).not.toBeNull(); - const {author, timestamp} = lastUpdateData!; - expect(author).not.toBeNull(); - expect(typeof author).toBe('string'); + const {lastUpdatedBy, lastUpdatedAt} = lastUpdateData!; + expect(lastUpdatedBy).not.toBeNull(); + expect(typeof lastUpdatedBy).toBe('string'); - expect(timestamp).not.toBeNull(); - expect(typeof timestamp).toBe('number'); + expect(lastUpdatedAt).not.toBeNull(); + expect(typeof lastUpdatedAt).toBe('number'); }); it('non-existing file', async () => { @@ -62,7 +61,7 @@ describe('getFileLastUpdate', () => { '__fixtures__', nonExistingFileName, ); - await expect(getFileLastUpdate(nonExistingFilePath)).rejects.toThrow( + await expect(getGitLastUpdate(nonExistingFilePath)).rejects.toThrow( /An error occurred when trying to get the last update date/, ); expect(consoleMock).toHaveBeenCalledTimes(0); @@ -74,7 +73,7 @@ describe('getFileLastUpdate', () => { const consoleMock = jest .spyOn(console, 'warn') .mockImplementation(() => {}); - const lastUpdateData = await getFileLastUpdate(existingFilePath); + const lastUpdateData = await getGitLastUpdate(existingFilePath); expect(lastUpdateData).toBeNull(); expect(consoleMock).toHaveBeenLastCalledWith( expect.stringMatching( @@ -92,7 +91,7 @@ describe('getFileLastUpdate', () => { .mockImplementation(() => {}); const tempFilePath = path.join(repoDir, 'file.md'); await fs.writeFile(tempFilePath, 'Lorem ipsum :)'); - await expect(getFileLastUpdate(tempFilePath)).resolves.toBeNull(); + await expect(getGitLastUpdate(tempFilePath)).resolves.toBeNull(); expect(consoleMock).toHaveBeenCalledTimes(1); expect(consoleMock).toHaveBeenLastCalledWith( expect.stringMatching(/not tracked by git./), @@ -113,7 +112,7 @@ describe('readLastUpdateData', () => { {date: testDate}, ); 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 () => { @@ -123,7 +122,7 @@ describe('readLastUpdateData', () => { {author: 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 () => { @@ -160,7 +159,7 @@ describe('readLastUpdateData', () => { {showLastUpdateAuthor: true, showLastUpdateTime: false}, {date: testDate}, ); - expect(lastUpdatedBy).toBe(GIT_FALLBACK_LAST_UPDATE_AUTHOR); + expect(lastUpdatedBy).toBe(LAST_UPDATE_FALLBACK.lastUpdatedBy); expect(lastUpdatedAt).toBeUndefined(); }); @@ -180,7 +179,7 @@ describe('readLastUpdateData', () => { {showLastUpdateAuthor: true, showLastUpdateTime: false}, {}, ); - expect(lastUpdatedBy).toBe(GIT_FALLBACK_LAST_UPDATE_AUTHOR); + expect(lastUpdatedBy).toBe(LAST_UPDATE_FALLBACK.lastUpdatedBy); expect(lastUpdatedAt).toBeUndefined(); }); @@ -201,7 +200,7 @@ describe('readLastUpdateData', () => { {author: testAuthor}, ); 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 () => { diff --git a/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts b/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts index 3927b09a7a..5c683e163c 100644 --- a/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/pathUtils.test.ts @@ -15,6 +15,7 @@ import { aliasedSitePath, toMessageRelativeFilePath, addTrailingPathSeparator, + aliasedSitePathToRelativePath, } from '../pathUtils'; 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', () => { it('works', () => { expect(addTrailingPathSeparator('foo')).toEqual( diff --git a/packages/docusaurus-utils/src/__tests__/routeUtils.test.ts b/packages/docusaurus-utils/src/__tests__/routeUtils.test.ts new file mode 100644 index 0000000000..733ecf726f --- /dev/null +++ b/packages/docusaurus-utils/src/__tests__/routeUtils.test.ts @@ -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], + ]); + }); +}); diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index 2f6e5ed929..2a47cfb614 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -91,6 +91,7 @@ export { posixPath, toMessageRelativeFilePath, aliasedSitePath, + aliasedSitePathToRelativePath, escapePath, addTrailingPathSeparator, } from './pathUtils'; @@ -118,12 +119,13 @@ export { export {isDraft, isUnlisted} from './contentVisibilityUtils'; export {escapeRegexp} from './regExpUtils'; export {askPreferredLanguage} from './cliUtils'; +export {flattenRoutes} from './routeUtils'; export { - getFileLastUpdate, + getGitLastUpdate, + getLastUpdate, + readLastUpdateData, + LAST_UPDATE_FALLBACK, type LastUpdateData, type FrontMatterLastUpdate, - readLastUpdateData, - GIT_FALLBACK_LAST_UPDATE_AUTHOR, - GIT_FALLBACK_LAST_UPDATE_DATE, } from './lastUpdateUtils'; diff --git a/packages/docusaurus-utils/src/lastUpdateUtils.ts b/packages/docusaurus-utils/src/lastUpdateUtils.ts index 52b884441c..a11dc782ef 100644 --- a/packages/docusaurus-utils/src/lastUpdateUtils.ts +++ b/packages/docusaurus-utils/src/lastUpdateUtils.ts @@ -14,22 +14,6 @@ import { } from './gitUtils'; 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 { - 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 = { /** A timestamp in **milliseconds**, usually read from `git log` */ lastUpdatedAt?: number; @@ -37,20 +21,12 @@ export type LastUpdateData = { 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 showedFileNotTrackedError = false; -export async function getFileLastUpdate( +export async function getGitLastUpdate( filePath: string, -): Promise<{timestamp: number; author: string} | null> { +): Promise { if (!filePath) { return null; } @@ -63,7 +39,7 @@ export async function getFileLastUpdate( includeAuthor: true, }); - return {timestamp: result.timestamp, author: result.author}; + return {lastUpdatedAt: result.timestamp, lastUpdatedBy: result.author}; } catch (err) { if (err instanceof GitNotFoundError) { 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 { + 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< PluginOptions, '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( filePath: string, options: LastUpdateOptions, @@ -111,18 +111,18 @@ export async function readLastUpdateData( // We try to minimize git last update calls // We call it at most once // If all the data is provided as front matter, we do not call it - const getGitLastUpdateMemoized = _.memoize(() => getGitLastUpdate(filePath)); - const getGitLastUpdateBy = () => - getGitLastUpdateMemoized().then((update) => update.lastUpdatedBy); - const getGitLastUpdateAt = () => - getGitLastUpdateMemoized().then((update) => update.lastUpdatedAt); + const getLastUpdateMemoized = _.memoize(() => getLastUpdate(filePath)); + const getLastUpdateBy = () => + getLastUpdateMemoized().then((update) => update?.lastUpdatedBy); + const getLastUpdateAt = () => + getLastUpdateMemoized().then((update) => update?.lastUpdatedAt); const lastUpdatedBy = showLastUpdateAuthor - ? frontMatterAuthor ?? (await getGitLastUpdateBy()) + ? frontMatterAuthor ?? (await getLastUpdateBy()) : undefined; const lastUpdatedAt = showLastUpdateTime - ? frontMatterTimestamp ?? (await getGitLastUpdateAt()) + ? frontMatterTimestamp ?? (await getLastUpdateAt()) : undefined; return { diff --git a/packages/docusaurus-utils/src/pathUtils.ts b/packages/docusaurus-utils/src/pathUtils.ts index d812f0490a..43ac9ba4c1 100644 --- a/packages/docusaurus-utils/src/pathUtils.ts +++ b/packages/docusaurus-utils/src/pathUtils.ts @@ -92,6 +92,20 @@ export function aliasedSitePath(filePath: string, siteDir: string): string { 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 * It is not safe to use directly when generating code diff --git a/packages/docusaurus-utils/src/routeUtils.ts b/packages/docusaurus-utils/src/routeUtils.ts new file mode 100644 index 0000000000..2dc565a21c --- /dev/null +++ b/packages/docusaurus-utils/src/routeUtils.ts @@ -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); +} diff --git a/packages/docusaurus/src/server/__tests__/routes.test.ts b/packages/docusaurus/src/server/__tests__/routes.test.ts index 278fbf9583..4b94520fec 100644 --- a/packages/docusaurus/src/server/__tests__/routes.test.ts +++ b/packages/docusaurus/src/server/__tests__/routes.test.ts @@ -6,33 +6,9 @@ */ import {jest} from '@jest/globals'; -import {getAllFinalRoutes, handleDuplicateRoutes} from '../routes'; +import {handleDuplicateRoutes} from '../routes'; 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', () => { const routes: RouteConfig[] = [ { diff --git a/packages/docusaurus/src/server/brokenLinks.ts b/packages/docusaurus/src/server/brokenLinks.ts index acdc016a1f..e3ad169260 100644 --- a/packages/docusaurus/src/server/brokenLinks.ts +++ b/packages/docusaurus/src/server/brokenLinks.ts @@ -13,9 +13,9 @@ import { parseURLPath, removeTrailingSlash, serializeURLPath, + flattenRoutes, type URLPath, } from '@docusaurus/utils'; -import {getAllFinalRoutes} from './routes'; import type {RouteConfig, ReportingSeverity} from '@docusaurus/types'; function matchRoutes(routeConfig: RouteConfig[], pathname: string) { @@ -188,7 +188,7 @@ function getBrokenLinksForPage({ */ function filterIntermediateRoutes(routesInput: RouteConfig[]): RouteConfig[] { const routesWithout404 = routesInput.filter((route) => route.path !== '*'); - return getAllFinalRoutes(routesWithout404); + return flattenRoutes(routesWithout404); } function getBrokenLinks({ diff --git a/packages/docusaurus/src/server/codegen/__tests__/__snapshots__/codegenRoutes.test.ts.snap b/packages/docusaurus/src/server/codegen/__tests__/__snapshots__/codegenRoutes.test.ts.snap index 2a6e4784b5..929a5257e0 100644 --- a/packages/docusaurus/src/server/codegen/__tests__/__snapshots__/codegenRoutes.test.ts.snap +++ b/packages/docusaurus/src/server/codegen/__tests__/__snapshots__/codegenRoutes.test.ts.snap @@ -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", }, "routesChunkNames": { - "/blog-599": { + "/blog-030": { "__comp": "__comp---theme-blog-list-pagea-6-a-7ba", "items": [ { @@ -31,7 +31,7 @@ import ComponentCreator from '@docusaurus/ComponentCreator'; export default [ { path: '/blog', - component: ComponentCreator('/blog', '599'), + component: ComponentCreator('/blog', '030'), exact: true }, { @@ -56,7 +56,7 @@ exports[`loadRoutes loads nested route config 1`] = ` "plugin---docs-hello-665-3ca": "pluginRouteContextModule-100.json", }, "routesChunkNames": { - "/docs/hello-fcc": { + "/docs/hello-ab4": { "__comp": "__comp---theme-doc-item-178-a40", "__context": { "plugin": "plugin---docs-hello-665-3ca", @@ -64,11 +64,11 @@ exports[`loadRoutes loads nested route config 1`] = ` "content": "content---docs-helloaff-811", "metadata": "metadata---docs-hello-956-741", }, - "/docs:route-9d0": { + "/docs:route-001": { "__comp": "__comp---theme-doc-roota-94-67a", "docsMetadata": "docsMetadata---docs-routef-34-881", }, - "docs/foo/baz-eb2": { + "docs/foo/baz-125": { "__comp": "__comp---theme-doc-item-178-a40", "__context": { "plugin": "plugin---docs-hello-665-3ca", @@ -83,17 +83,17 @@ import ComponentCreator from '@docusaurus/ComponentCreator'; export default [ { path: '/docs:route', - component: ComponentCreator('/docs:route', '9d0'), + component: ComponentCreator('/docs:route', '001'), routes: [ { path: '/docs/hello', - component: ComponentCreator('/docs/hello', 'fcc'), + component: ComponentCreator('/docs/hello', 'ab4'), exact: true, sidebar: "main" }, { path: 'docs/foo/baz', - component: ComponentCreator('docs/foo/baz', 'eb2'), + component: ComponentCreator('docs/foo/baz', '125'), sidebar: "secondary", "key:a": "containing colon", "key'b": "containing quote", diff --git a/packages/docusaurus/src/server/codegen/__tests__/codegenRoutes.test.ts b/packages/docusaurus/src/server/codegen/__tests__/codegenRoutes.test.ts index bfcfe12347..c1e55c9178 100644 --- a/packages/docusaurus/src/server/codegen/__tests__/codegenRoutes.test.ts +++ b/packages/docusaurus/src/server/codegen/__tests__/codegenRoutes.test.ts @@ -108,6 +108,10 @@ describe('loadRoutes', () => { content: 'docs/hello.md', metadata: 'docs-hello-da2.json', }, + metadata: { + sourceFilePath: 'docs/hello.md', + lastUpdatedAt: 1710842708527, + }, context: { plugin: 'pluginRouteContextModule-100.json', }, @@ -120,6 +124,10 @@ describe('loadRoutes', () => { content: 'docs/foo/baz.md', metadata: 'docs-foo-baz-dd9.json', }, + metadata: { + sourceFilePath: 'docs/foo/baz.md', + lastUpdatedAt: 1710842708527, + }, context: { plugin: 'pluginRouteContextModule-100.json', }, @@ -142,6 +150,9 @@ describe('loadRoutes', () => { path: '/blog', component: '@theme/BlogListPage', exact: true, + metadata: { + lastUpdatedAt: 1710842708527, + }, modules: { items: [ { diff --git a/packages/docusaurus/src/server/codegen/codegenRoutes.ts b/packages/docusaurus/src/server/codegen/codegenRoutes.ts index 8306e0f78d..8eee10d3f6 100644 --- a/packages/docusaurus/src/server/codegen/codegenRoutes.ts +++ b/packages/docusaurus/src/server/codegen/codegenRoutes.ts @@ -200,6 +200,7 @@ function genRouteCode(routeConfig: RouteConfig, res: RoutesCode): string { routes: subroutes, priority, exact, + metadata, ...props } = routeConfig; diff --git a/packages/docusaurus/src/server/plugins/plugins.ts b/packages/docusaurus/src/server/plugins/plugins.ts index 3fa102ef90..57d6ac30a3 100644 --- a/packages/docusaurus/src/server/plugins/plugins.ts +++ b/packages/docusaurus/src/server/plugins/plugins.ts @@ -18,10 +18,10 @@ import type { RouteConfig, AllContent, GlobalData, + PluginIdentifier, LoadedPlugin, InitializedPlugin, } from '@docusaurus/types'; -import type {PluginIdentifier} from '@docusaurus/types/src/plugin'; async function translatePlugin({ plugin, diff --git a/packages/docusaurus/src/server/routes.ts b/packages/docusaurus/src/server/routes.ts index 95621d34da..6fe40b7da3 100644 --- a/packages/docusaurus/src/server/routes.ts +++ b/packages/docusaurus/src/server/routes.ts @@ -6,17 +6,9 @@ */ import logger from '@docusaurus/logger'; -import {normalizeUrl} from '@docusaurus/utils'; +import {normalizeUrl, flattenRoutes} from '@docusaurus/utils'; 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( routes: RouteConfig[], onDuplicateRoutes: ReportingSeverity, @@ -24,7 +16,7 @@ export function handleDuplicateRoutes( if (onDuplicateRoutes === 'ignore') { return; } - const allRoutes: string[] = getAllFinalRoutes(routes).map( + const allRoutes: string[] = flattenRoutes(routes).map( (routeConfig) => routeConfig.path, ); const seenRoutes = new Set(); @@ -52,6 +44,13 @@ This could lead to non-deterministic routing behavior.`; * This is rendered through the catch-all ComponentCreator("*") route * Note CDNs only understand the 404.html file by convention * 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'; @@ -61,6 +60,6 @@ export function getRoutesPaths( ): string[] { return [ normalizeUrl([baseUrl, NotFoundRoutePath]), - ...getAllFinalRoutes(routeConfigs).map((r) => r.path), + ...flattenRoutes(routeConfigs).map((r) => r.path), ]; } diff --git a/packages/docusaurus/src/server/site.ts b/packages/docusaurus/src/server/site.ts index a5148781f9..961184d731 100644 --- a/packages/docusaurus/src/server/site.ts +++ b/packages/docusaurus/src/server/site.ts @@ -31,8 +31,8 @@ import type { GlobalData, LoadContext, Props, + PluginIdentifier, } from '@docusaurus/types'; -import type {PluginIdentifier} from '@docusaurus/types/src/plugin'; export type LoadContextParams = { /** Usually the CWD; can be overridden with command argument. */ diff --git a/project-words.txt b/project-words.txt index 3717bb2330..891d1c1ff4 100644 --- a/project-words.txt +++ b/project-words.txt @@ -42,7 +42,6 @@ cdabcdab cdpath Cena cena -Changefreq changefreq Chedeau chedeau @@ -157,6 +156,8 @@ Knapen Koyeb Koyeb's Lamana +Lastmod +lastmod Lifecycles lifecycles Linkify diff --git a/website/docs/api/plugin-methods/lifecycle-apis.mdx b/website/docs/api/plugin-methods/lifecycle-apis.mdx index f323095e32..4606eb6775 100644 --- a/website/docs/api/plugin-methods/lifecycle-apis.mdx +++ b/website/docs/api/plugin-methods/lifecycle-apis.mdx @@ -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. ```ts -type RouteConfig = { +export type RouteConfig = { + /** + * With leading slash. Trailing slash will be normalized by config. + */ path: string; + /** + * Component used to render this route, a path that the bundler can `require`. + */ component: string; + /** + * Props. Each entry should be `[propName]: pathToPropModule` (created with + * `createData`) + */ 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[]; + /** + * React router config option: `exact` routes would not match subroutes. + */ 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; + /** + * 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 = { [module: string]: Module | RouteModules | RouteModules[]; }; + type Module = | { path: string; diff --git a/website/docs/api/plugins/plugin-sitemap.mdx b/website/docs/api/plugins/plugin-sitemap.mdx index 29832b4ddb..41f2408394 100644 --- a/website/docs/api/plugins/plugin-sitemap.mdx +++ b/website/docs/api/plugins/plugin-sitemap.mdx @@ -39,8 +39,9 @@ Accepted fields: | Name | Type | Default | Description | | --- | --- | --- | --- | -| `changefreq` | `string` | `'weekly'` | See [sitemap docs](https://www.sitemaps.org/protocol.html#xmlTagDefinitions) | -| `priority` | `number` | `0.5` | 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). | +| `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. | | `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 `` 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 `` tag for their routes. + +::: + ### Example configuration {#ex-config} 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 const config = { + lastmod: 'date', changefreq: 'weekly', priority: 0.5, ignorePatterns: ['/tags/**'], diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index b0c668fa6d..bfe47fedd5 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -480,6 +480,9 @@ export default async function createConfigAsync() { sitemap: { // Note: /tests/docs already has noIndex: true ignorePatterns: ['/tests/{blog,pages}/**'], + lastmod: 'date', + priority: null, + changefreq: null, }, } satisfies Preset.Options, ],