diff --git a/packages/docusaurus-types/src/config.d.ts b/packages/docusaurus-types/src/config.d.ts index ec1c8aee7e..2cda16ca62 100644 --- a/packages/docusaurus-types/src/config.d.ts +++ b/packages/docusaurus-types/src/config.d.ts @@ -40,6 +40,22 @@ export type FutureV4Config = { useCssCascadeLayers: boolean; }; +// VCS (Version Control System) info about a given change, e.g., a git commit. +// The agnostic term "VCS" is used instead of "git" to acknowledge the existence +// of other version control systems, and external systems like CMSs and i18n +// translation SaaS (e.g., Crowdin) +type VcsChangeInfo = {timestamp: number; author: string}; + +// VCS (Version Control System) config hooks to get file change info. +// This lets you override and customize the default Docusaurus behavior. +// This can be useful to optimize calls or when using something else than git +// See https://github.com/facebook/docusaurus/issues/11208 +// See https://github.com/e18e/ecosystem-issues/issues/216 +export type VcsConfig = { + getFileCreationInfo: (filePath: string) => Promise; + getFileLastUpdateInfo: (filePath: string) => Promise; +}; + export type FutureConfig = { /** * Turns v4 future flags on @@ -50,6 +66,8 @@ export type FutureConfig = { experimental_storage: StorageConfig; + experimental_vcs: VcsConfig; + /** * Docusaurus can work with 2 router types. * diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index d7e61f569d..3032f0810e 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -13,6 +13,7 @@ export { FutureV4Config, FasterConfig, StorageConfig, + VcsConfig, Config, } from './config'; diff --git a/packages/docusaurus-utils/src/gitUtils.ts b/packages/docusaurus-utils/src/gitUtils.ts index 890c35bd89..fd1891f257 100644 --- a/packages/docusaurus-utils/src/gitUtils.ts +++ b/packages/docusaurus-utils/src/gitUtils.ts @@ -126,6 +126,7 @@ export async function getFileCommitDate( timestamp: number; author?: string; }> { + console.log({file}); if (!hasGit()) { throw new GitNotFoundError( `Failed to retrieve git history for "${file}" because git is not installed.`, diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index 9370af8885..46c8616e48 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -129,4 +129,6 @@ export { type FrontMatterLastUpdate, } from './lastUpdateUtils'; +export {DEFAULT_VCS_CONFIG} from './vcsUtils'; + export {normalizeTags, reportInlineTags} from './tags'; diff --git a/packages/docusaurus-utils/src/vcsUtils.ts b/packages/docusaurus-utils/src/vcsUtils.ts new file mode 100644 index 0000000000..640163da1b --- /dev/null +++ b/packages/docusaurus-utils/src/vcsUtils.ts @@ -0,0 +1,32 @@ +/** + * 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 {getFileCommitDate} from './gitUtils'; +import {getLastUpdate} from './lastUpdateUtils'; +import type {VcsConfig} from '@docusaurus/types'; + +export const DEFAULT_VCS_CONFIG: VcsConfig = { + getFileCreationInfo: async (filePath: string) => { + return getFileCommitDate(filePath, { + age: 'oldest', + includeAuthor: true, + }); + }, + getFileLastUpdateInfo: async (filePath: string) => { + // TODO non-ideal integration but good enough for now + // This keeps this new VscConfig system retro-compatible with the existing + // historical Docusaurus behavior based on Git + const result = await getLastUpdate(filePath); + if (result === null) { + return null; + } + return { + timestamp: result.lastUpdatedAt!, + author: result.lastUpdatedBy!, + }; + }, +}; diff --git a/packages/docusaurus/src/server/__tests__/configValidation.test.ts b/packages/docusaurus/src/server/__tests__/configValidation.test.ts index 678cbc4ef5..f159139832 100644 --- a/packages/docusaurus/src/server/__tests__/configValidation.test.ts +++ b/packages/docusaurus/src/server/__tests__/configValidation.test.ts @@ -6,6 +6,7 @@ */ import {jest} from '@jest/globals'; +import {DEFAULT_VCS_CONFIG} from '@docusaurus/utils'; import { ConfigSchema, DEFAULT_CONFIG, @@ -29,6 +30,7 @@ import type { PluginConfig, I18nConfig, I18nLocaleConfig, + VcsConfig, } from '@docusaurus/types'; import type {DeepPartial} from 'utility-types'; @@ -73,6 +75,10 @@ describe('normalizeConfig', () => { type: 'sessionStorage', namespace: true, }, + experimental_vcs: { + getFileCreationInfo: (_filePath) => null, + getFileLastUpdateInfo: (_filePath) => null, + }, experimental_router: 'hash', }, tagline: 'my awesome site', @@ -1077,6 +1083,10 @@ describe('future', () => { rspackPersistentCache: true, ssgWorkerThreads: true, }, + experimental_vcs: { + getFileCreationInfo: (_filePath) => null, + getFileLastUpdateInfo: (_filePath) => null, + }, experimental_storage: { type: 'sessionStorage', namespace: 'myNamespace', @@ -1394,6 +1404,284 @@ describe('future', () => { }); }); + describe('vcs', () => { + function vcsContaining(vcs: Partial) { + return futureContaining({ + experimental_vcs: expect.objectContaining(vcs), + }); + } + + it('accepts vcs - undefined', () => { + expect( + normalizeConfig({ + future: { + experimental_vcs: undefined, + }, + }), + ).toEqual(futureContaining(DEFAULT_FUTURE_CONFIG)); + }); + + it('accepts vcs - empty', () => { + expect( + normalizeConfig({ + future: {experimental_vcs: {}}, + }), + ).toEqual(futureContaining(DEFAULT_FUTURE_CONFIG)); + }); + + it('accepts vcs - full', () => { + const vcs: VcsConfig = { + getFileCreationInfo: (_filePath) => null, + getFileLastUpdateInfo: (_filePath) => null, + }; + expect( + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toEqual(vcsContaining(vcs)); + }); + + it('rejects vcs - boolean', () => { + // @ts-expect-error: invalid + const vcs: Partial = true; + expect(() => + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_vcs" must be of type object + " + `); + }); + + it('rejects vcs - number', () => { + // @ts-expect-error: invalid + const vcs: Partial = 42; + expect(() => + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_vcs" must be of type object + " + `); + }); + + describe('getFileCreationInfo', () => { + it('accepts fn(filePath)', () => { + const vcs: Partial = { + getFileCreationInfo: (_filePath) => null, + }; + expect( + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toEqual( + vcsContaining({ + ...DEFAULT_VCS_CONFIG, + ...vcs, + }), + ); + }); + + it('accepts undefined', () => { + const vcs: Partial = { + getFileCreationInfo: undefined, + }; + expect( + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toEqual( + vcsContaining({ + ...DEFAULT_VCS_CONFIG, + }), + ); + }); + + it('rejects null', () => { + const vcs: Partial = { + // @ts-expect-error: invalid + getFileCreationInfo: null, + }; + expect(() => + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_vcs.getFileCreationInfo" must be of type function + " + `); + }); + + it('rejects number', () => { + const vcs: Partial = { + // @ts-expect-error: invalid + getFileCreationInfo: 42, + }; + expect(() => + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_vcs.getFileCreationInfo" must be of type function + " + `); + }); + + it('rejects fn()', () => { + const vcs: Partial = { + getFileCreationInfo: () => null, + }; + expect(() => + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_vcs.getFileCreationInfo" must have an arity of 1 + " + `); + }); + + it('rejects fn(filePath, anotherArg)', () => { + const vcs: Partial = { + // @ts-expect-error: invalid + getFileCreationInfo: (_filePath, _anotherArg) => null, + }; + expect(() => + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_vcs.getFileCreationInfo" must have an arity of 1 + " + `); + }); + }); + + describe('getFileLastUpdateInfo', () => { + it('accepts fn(filePath)', () => { + const vcs: Partial = { + getFileLastUpdateInfo: (_filePath) => null, + }; + expect( + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toEqual( + vcsContaining({ + ...DEFAULT_VCS_CONFIG, + ...vcs, + }), + ); + }); + + it('accepts undefined', () => { + const vcs: Partial = { + getFileLastUpdateInfo: undefined, + }; + expect( + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toEqual( + vcsContaining({ + ...DEFAULT_VCS_CONFIG, + }), + ); + }); + + it('rejects null', () => { + const vcs: Partial = { + // @ts-expect-error: invalid + getFileLastUpdateInfo: null, + }; + expect(() => + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_vcs.getFileLastUpdateInfo" must be of type function + " + `); + }); + + it('rejects number', () => { + const vcs: Partial = { + // @ts-expect-error: invalid + getFileLastUpdateInfo: 42, + }; + expect(() => + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_vcs.getFileLastUpdateInfo" must be of type function + " + `); + }); + + it('rejects fn()', () => { + const vcs: Partial = { + getFileLastUpdateInfo: () => null, + }; + expect(() => + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_vcs.getFileLastUpdateInfo" must have an arity of 1 + " + `); + }); + + it('rejects fn(filePath, anotherArg)', () => { + const vcs: Partial = { + // @ts-expect-error: invalid + getFileLastUpdateInfo: (_filePath, _anotherArg) => null, + }; + expect(() => + normalizeConfig({ + future: { + experimental_vcs: vcs, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_vcs.getFileLastUpdateInfo" must have an arity of 1 + " + `); + }); + }); + }); + describe('faster', () => { function fasterContaining(faster: Partial) { return futureContaining({ diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index ce69e43e46..221c4c0488 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -9,6 +9,7 @@ import { DEFAULT_PARSE_FRONT_MATTER, DEFAULT_STATIC_DIR_NAME, DEFAULT_I18N_DIR_NAME, + DEFAULT_VCS_CONFIG, } from '@docusaurus/utils'; import {Joi, printWarning} from '@docusaurus/utils-validation'; import { @@ -27,6 +28,7 @@ import type { MarkdownConfig, MarkdownHooks, I18nLocaleConfig, + VcsConfig, } from '@docusaurus/types'; const DEFAULT_I18N_LOCALE = 'en'; @@ -106,6 +108,7 @@ export const DEFAULT_FUTURE_CONFIG: FutureConfig = { v4: DEFAULT_FUTURE_V4_CONFIG, experimental_faster: DEFAULT_FASTER_CONFIG, experimental_storage: DEFAULT_STORAGE_CONFIG, + experimental_vcs: DEFAULT_VCS_CONFIG, experimental_router: 'browser', }; @@ -331,10 +334,24 @@ const STORAGE_CONFIG_SCHEMA = Joi.object({ .optional() .default(DEFAULT_STORAGE_CONFIG); +const VCS_CONFIG_SCHEMA = Joi.object({ + getFileCreationInfo: Joi.function() + .arity(1) + .optional() + .default(() => DEFAULT_VCS_CONFIG.getFileCreationInfo), + getFileLastUpdateInfo: Joi.function() + .arity(1) + .optional() + .default(() => DEFAULT_VCS_CONFIG.getFileLastUpdateInfo), +}) + .optional() + .default(DEFAULT_VCS_CONFIG); + const FUTURE_CONFIG_SCHEMA = Joi.object({ v4: FUTURE_V4_SCHEMA, experimental_faster: FASTER_CONFIG_SCHEMA, experimental_storage: STORAGE_CONFIG_SCHEMA, + experimental_vcs: VCS_CONFIG_SCHEMA, experimental_router: Joi.string() .equal('browser', 'hash') .default(DEFAULT_FUTURE_CONFIG.experimental_router),