Refactor VCS preset + config validation

This commit is contained in:
sebastien 2025-11-06 17:26:23 +01:00
parent ab790703b2
commit acf5f383ea
9 changed files with 186 additions and 331 deletions

View File

@ -129,5 +129,6 @@
"stylelint-config-standard": "^29.0.0",
"typescript": "~5.8.2"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"dependencies": {}
}

View File

@ -76,6 +76,11 @@ export type VcsConfig = {
getFileLastUpdateInfo: (filePath: string) => Promise<VcsChangeInfo | null>;
};
/**
* List of pre-built VcsConfig that Docusaurus provides.
*/
export type VcsPreset = 'git-ad-hoc' | 'git-eager' | 'hardcoded';
export type FutureConfig = {
/**
* Turns v4 future flags on
@ -86,7 +91,7 @@ export type FutureConfig = {
experimental_storage: StorageConfig;
experimental_vcs: VcsConfig;
experimental_vcs: VcsPreset | VcsConfig;
/**
* Docusaurus can work with 2 router types.

View File

@ -14,6 +14,7 @@ export {
FasterConfig,
StorageConfig,
VcsConfig,
VcsPreset,
VcsChangeInfo,
VscInitializeParams,
Config,

View File

@ -131,6 +131,11 @@ export {
type FrontMatterLastUpdate,
} from './lastUpdateUtils';
export {DEFAULT_VCS_CONFIG, DEFAULT_TEST_VCS_CONFIG} from './vcs/vcs';
export {
VcsPresetNames,
getVcsPreset,
getDefaultVcsConfig,
DEFAULT_TEST_VCS_CONFIG,
} from './vcs/vcs';
export {normalizeTags, reportInlineTags} from './tags';

View File

@ -8,31 +8,59 @@
import {VcsHardcoded} from './vcsHardcoded';
import {VcsGitAdHoc} from './vcsGitAdHoc';
import {VscGitEager} from './vcsGitEager';
import type {VcsConfig} from '@docusaurus/types';
import type {VcsConfig, VcsPreset} from '@docusaurus/types';
const VcsPresets = {
const VcsPresets: Record<VcsPreset, VcsConfig> = {
'git-ad-hoc': VcsGitAdHoc,
'git-eager': VscGitEager,
hardcoded: VcsHardcoded,
} as const satisfies Record<string, VcsConfig>;
};
type VscPresetName = keyof typeof VcsPresets;
export const VcsPresetNames = Object.keys(VcsPresets) as VcsPreset[];
function getVcsPreset(presetName: VscPresetName): VcsConfig {
return VcsPresets[presetName];
export function findVcsPreset(presetName: string): VcsConfig | undefined {
return VcsPresets[presetName as VcsPreset];
}
function getDefaultVcsConfig(): VcsConfig {
export function getVcsPreset(presetName: VcsPreset): VcsConfig {
const vcs = findVcsPreset(presetName);
if (vcs) {
return vcs;
} else {
throw new Error(
`Unknown Docusaurus VCS preset name: ${process.env.DOCUSAURUS_VCS}`,
);
}
}
export function getDefaultVcsPreset(): VcsPreset {
// Escape hatch to override the default VCS preset we use
if (process.env.DOCUSAURUS_VCS) {
const vcs = getVcsPreset(process.env.DOCUSAURUS_VCS as VscPresetName);
if (vcs) {
return vcs;
} else {
throw new Error(
`Unknown DOCUSAURUS_VCS preset name: ${process.env.DOCUSAURUS_VCS}`,
);
}
return process.env.DOCUSAURUS_VCS as VcsPreset;
}
if (process.env.NODE_ENV === 'production') {
// TODO add feature flag switch for git-eager / git-ad-hoc strategies
// return getVcsPreset('git-ad-hoc');
return 'git-eager';
}
// Return hardcoded values in dev to improve DX
if (process.env.NODE_ENV === 'development') {
return 'hardcoded';
}
// Return hardcoded values in test to make tests simpler and faster
if (process.env.NODE_ENV === 'test') {
return 'hardcoded';
}
return 'git-eager';
}
export function getDefaultVcsConfig(): VcsConfig {
// Escape hatch to override the default VCS preset we use
if (process.env.DOCUSAURUS_VCS) {
return getVcsPreset(process.env.DOCUSAURUS_VCS as VcsPreset);
}
if (process.env.NODE_ENV === 'production') {
@ -40,7 +68,6 @@ function getDefaultVcsConfig(): VcsConfig {
// return getVcsPreset('git-ad-hoc');
return getVcsPreset('git-eager');
}
// Return hardcoded values in dev to improve DX
if (process.env.NODE_ENV === 'development') {
return getVcsPreset('hardcoded');
@ -55,5 +82,3 @@ function getDefaultVcsConfig(): VcsConfig {
}
export const DEFAULT_TEST_VCS_CONFIG: VcsConfig = VcsHardcoded;
export const DEFAULT_VCS_CONFIG: VcsConfig = getDefaultVcsConfig();

View File

@ -163,6 +163,7 @@ function createGitVcsConfig(): VcsConfig {
return {
initialize: ({siteDir}) => {
console.log('git eager init');
// Only pre-init for production builds
getGitFileInfo(siteDir).catch((error) => {
console.error(

View File

@ -6,7 +6,7 @@
*/
import {jest} from '@jest/globals';
import {DEFAULT_VCS_CONFIG} from '@docusaurus/utils';
import {DEFAULT_TEST_VCS_CONFIG, getVcsPreset} from '@docusaurus/utils';
import {
ConfigSchema,
DEFAULT_CONFIG,
@ -31,6 +31,7 @@ import type {
I18nConfig,
I18nLocaleConfig,
VcsConfig,
VcsPreset,
} from '@docusaurus/types';
import type {DeepPartial} from 'utility-types';
@ -1413,128 +1414,25 @@ describe('future', () => {
});
}
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 = {
initialize: (_params) => {},
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<VcsConfig> = 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<VcsConfig> = 42;
expect(() =>
normalizeConfig({
future: {
experimental_vcs: vcs,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_vcs" must be of type object
"
`);
});
describe('initialize', () => {
it('accepts fn(params)', () => {
const vcs: Partial<VcsConfig> = {
initialize: (_params) => null,
};
describe('base', () => {
it('accepts vcs - undefined', () => {
expect(
normalizeConfig({
future: {
experimental_vcs: vcs,
experimental_vcs: undefined,
},
}),
).toEqual(
vcsContaining({
...DEFAULT_VCS_CONFIG,
...vcs,
futureContaining({
...DEFAULT_FUTURE_CONFIG,
experimental_vcs: DEFAULT_TEST_VCS_CONFIG,
}),
);
});
it('accepts fn()', () => {
const vcs: Partial<VcsConfig> = {
initialize: () => null,
};
expect(
normalizeConfig({
future: {
experimental_vcs: vcs,
},
}),
).toEqual(
vcsContaining({
...DEFAULT_VCS_CONFIG,
...vcs,
}),
);
});
it('accepts undefined', () => {
const vcs: Partial<VcsConfig> = {
initialize: undefined,
};
expect(
normalizeConfig({
future: {
experimental_vcs: vcs,
},
}),
).toEqual(
vcsContaining({
...DEFAULT_VCS_CONFIG,
}),
);
});
it('rejects null', () => {
const vcs: Partial<VcsConfig> = {
// @ts-expect-error: invalid
initialize: null,
};
it('rejects vcs - boolean', () => {
// @ts-expect-error: invalid
const vcs: Partial<VcsConfig> = true;
expect(() =>
normalizeConfig({
future: {
@ -1542,16 +1440,14 @@ describe('future', () => {
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_vcs.initialize" must be of type function
""future.experimental_vcs" failed custom validation because "value" must be of type object
"
`);
});
it('rejects number', () => {
const vcs: Partial<VcsConfig> = {
// @ts-expect-error: invalid
initialize: 42,
};
it('rejects vcs - number', () => {
// @ts-expect-error: invalid
const vcs: Partial<VcsConfig> = 42;
expect(() =>
normalizeConfig({
future: {
@ -1559,136 +1455,67 @@ describe('future', () => {
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_vcs.initialize" must be of type function
"
`);
});
it('rejects fn(params, anotherArg)', () => {
const vcs: Partial<VcsConfig> = {
// @ts-expect-error: invalid
initialize: (_params, _anotherArg) => null,
};
expect(() =>
normalizeConfig({
future: {
experimental_vcs: vcs,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_vcs.initialize" must have an arity lesser or equal to 1
""future.experimental_vcs" failed custom validation because "value" must be of type object
"
`);
});
});
describe('getFileCreationInfo', () => {
it('accepts fn(filePath)', () => {
const vcs: Partial<VcsConfig> = {
describe('presets', () => {
it('accepts git-ad-hoc', () => {
const presetName: VcsPreset = 'git-ad-hoc';
expect(
normalizeConfig({
future: {
experimental_vcs: presetName,
},
}),
).toEqual(vcsContaining(getVcsPreset(presetName)));
});
it('accepts git-eager', () => {
const presetName: VcsPreset = 'git-eager';
expect(
normalizeConfig({
future: {
experimental_vcs: presetName,
},
}),
).toEqual(vcsContaining(getVcsPreset(presetName)));
});
it('accepts hardcoded', () => {
const presetName: VcsPreset = 'hardcoded';
expect(
normalizeConfig({
future: {
experimental_vcs: presetName,
},
}),
).toEqual(vcsContaining(getVcsPreset(presetName)));
});
it('rejects unknown preset name', () => {
// @ts-expect-error: invalid on purpose
const presetName: VcsPreset = 'unknown-preset-name';
expect(() =>
normalizeConfig({
future: {
experimental_vcs: presetName,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_vcs" failed custom validation because VCS config preset name 'unknown-preset-name' is not valid.
"
`);
});
});
describe('object config', () => {
it('accepts vcs - full', () => {
const vcs: VcsConfig = {
initialize: (_params) => {},
getFileCreationInfo: (_filePath) => null,
};
expect(
normalizeConfig({
future: {
experimental_vcs: vcs,
},
}),
).toEqual(
vcsContaining({
...DEFAULT_VCS_CONFIG,
...vcs,
}),
);
});
it('accepts undefined', () => {
const vcs: Partial<VcsConfig> = {
getFileCreationInfo: undefined,
};
expect(
normalizeConfig({
future: {
experimental_vcs: vcs,
},
}),
).toEqual(
vcsContaining({
...DEFAULT_VCS_CONFIG,
}),
);
});
it('rejects null', () => {
const vcs: Partial<VcsConfig> = {
// @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<VcsConfig> = {
// @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<VcsConfig> = {
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<VcsConfig> = {
// @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<VcsConfig> = {
getFileLastUpdateInfo: (_filePath) => null,
};
expect(
@ -1697,52 +1524,26 @@ describe('future', () => {
experimental_vcs: vcs,
},
}),
).toEqual(
vcsContaining({
...DEFAULT_VCS_CONFIG,
...vcs,
}),
);
).toEqual(vcsContaining(vcs));
});
it('accepts undefined', () => {
const vcs: Partial<VcsConfig> = {
getFileLastUpdateInfo: undefined,
};
expect(
normalizeConfig({
future: {
experimental_vcs: vcs,
},
}),
).toEqual(
vcsContaining({
...DEFAULT_VCS_CONFIG,
}),
);
});
it('rejects null', () => {
const vcs: Partial<VcsConfig> = {
// @ts-expect-error: invalid
getFileLastUpdateInfo: null,
};
it('rejects vcs - empty', () => {
expect(() =>
normalizeConfig({
future: {
experimental_vcs: vcs,
},
future: {experimental_vcs: {}},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_vcs.getFileLastUpdateInfo" must be of type function
""future.experimental_vcs" failed custom validation because "initialize" is required
"
`);
});
it('rejects number', () => {
const vcs: Partial<VcsConfig> = {
// @ts-expect-error: invalid
getFileLastUpdateInfo: 42,
it('accepts vcs - bad initialize() arity', () => {
const vcs: VcsConfig = {
// @ts-expect-error: invalid arity
initialize: (_params, _extraParam) => {},
getFileCreationInfo: (_filePath) => null,
getFileLastUpdateInfo: (_filePath) => null,
};
expect(() =>
normalizeConfig({
@ -1751,14 +1552,17 @@ describe('future', () => {
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_vcs.getFileLastUpdateInfo" must be of type function
""future.experimental_vcs" failed custom validation because "initialize" must have an arity lesser or equal to 1
"
`);
});
it('rejects fn()', () => {
const vcs: Partial<VcsConfig> = {
getFileLastUpdateInfo: () => null,
it('accepts vcs - bad getFileCreationInfo() arity', () => {
const vcs: VcsConfig = {
initialize: (_params) => {},
// @ts-expect-error: invalid arity
getFileCreationInfo: (_filePath, _extraParam) => null,
getFileLastUpdateInfo: (_filePath) => null,
};
expect(() =>
normalizeConfig({
@ -1767,15 +1571,17 @@ describe('future', () => {
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_vcs.getFileLastUpdateInfo" must have an arity of 1
""future.experimental_vcs" failed custom validation because "getFileCreationInfo" must have an arity of 1
"
`);
});
it('rejects fn(filePath, anotherArg)', () => {
const vcs: Partial<VcsConfig> = {
// @ts-expect-error: invalid
getFileLastUpdateInfo: (_filePath, _anotherArg) => null,
it('accepts vcs - bad getFileLastUpdateInfo() arity', () => {
const vcs: VcsConfig = {
initialize: (_params) => {},
getFileCreationInfo: (_filePath) => null,
// @ts-expect-error: invalid arity
getFileLastUpdateInfo: (_filePath, _extraParam) => null,
};
expect(() =>
normalizeConfig({
@ -1784,7 +1590,7 @@ describe('future', () => {
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_vcs.getFileLastUpdateInfo" must have an arity of 1
""future.experimental_vcs" failed custom validation because "getFileLastUpdateInfo" must have an arity of 1
"
`);
});

View File

@ -9,7 +9,9 @@ import {
DEFAULT_PARSE_FRONT_MATTER,
DEFAULT_STATIC_DIR_NAME,
DEFAULT_I18N_DIR_NAME,
DEFAULT_VCS_CONFIG,
getDefaultVcsConfig,
VcsPresetNames,
getVcsPreset,
} from '@docusaurus/utils';
import {Joi, printWarning} from '@docusaurus/utils-validation';
import {
@ -29,6 +31,7 @@ import type {
MarkdownHooks,
I18nLocaleConfig,
VcsConfig,
VcsPreset,
} from '@docusaurus/types';
const DEFAULT_I18N_LOCALE = 'en';
@ -108,7 +111,9 @@ 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,
// Not good, need to be loaded lazily
experimental_vcs: getDefaultVcsConfig(),
experimental_router: 'browser',
};
@ -334,22 +339,28 @@ const STORAGE_CONFIG_SCHEMA = Joi.object({
.optional()
.default(DEFAULT_STORAGE_CONFIG);
const VCS_CONFIG_SCHEMA = Joi.object<VcsConfig>({
initialize: Joi.function()
.maxArity(1)
.optional()
.default(() => DEFAULT_VCS_CONFIG.initialize),
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 VCS_CONFIG_OBJECT_SCHEMA = Joi.object<VcsConfig>({
// All the fields are required on purpose
// You either provide a full VCS config or nothing
initialize: Joi.function().maxArity(1).required(),
getFileCreationInfo: Joi.function().arity(1).required(),
getFileLastUpdateInfo: Joi.function().arity(1).required(),
});
const VCS_CONFIG_SCHEMA = Joi.custom((input) => {
if (typeof input === 'string') {
const presetName = input as VcsPreset;
if (!VcsPresetNames.includes(presetName)) {
throw new Error(`VCS config preset name '${input}' is not valid.`);
}
return getVcsPreset(presetName);
}
const {error, value} = VCS_CONFIG_OBJECT_SCHEMA.validate(input);
if (error) {
throw error;
}
return value;
}).default(() => getDefaultVcsConfig());
const FUTURE_CONFIG_SCHEMA = Joi.object<FutureConfig>({
v4: FUTURE_V4_SCHEMA,

View File

@ -6088,9 +6088,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001718:
version "1.0.30001721"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001721.tgz#36b90cd96901f8c98dd6698bf5c8af7d4c6872d7"
integrity sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==
version "1.0.30001754"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz"
integrity sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==
ccount@^2.0.0:
version "2.0.1"