fix(docs): prevent docs ids conflicts within a version (#11251)

This commit is contained in:
Sébastien Lorber 2025-06-06 20:12:44 +02:00 committed by GitHub
parent f811e2dbf4
commit b54103be5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 323 additions and 127 deletions

View file

@ -0,0 +1,7 @@
---
# no id but should conflict due to the name anyway
---
# Hello
World

View file

@ -0,0 +1,3 @@
[
"with-id-conflicts"
]

View file

@ -1,12 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`minimal site can load current version 1`] = `
exports[`loadVersion minimal site can load current version 1`] = `
{
"badge": false,
"banner": null,
"className": "docs-version-current",
"contentPath": "<PROJECT_ROOT>/packages/docusaurus-plugin-content-docs/src/versions/__tests__/__fixtures__/minimal-site/docs",
"contentPathLocalized": "<PROJECT_ROOT>/packages/docusaurus-plugin-content-docs/src/versions/__tests__/__fixtures__/minimal-site/i18n/en/docusaurus-plugin-content-docs/current",
"contentPath": "<PROJECT_ROOT>/packages/docusaurus-plugin-content-docs/src/versions/__tests__/__fixtures__/site-minimal/docs",
"contentPathLocalized": "<PROJECT_ROOT>/packages/docusaurus-plugin-content-docs/src/versions/__tests__/__fixtures__/site-minimal/i18n/en/docusaurus-plugin-content-docs/current",
"docs": [
{
"description": "World",

View file

@ -22,52 +22,96 @@ const DefaultI18N: I18n = {
localeConfigs: {},
};
describe('minimal site', () => {
async function loadSite() {
const siteDir = path.resolve(
path.join(__dirname, './__fixtures__', 'minimal-site'),
);
const options: PluginOptions = fromPartial<PluginOptions>({
...DEFAULT_OPTIONS,
});
const context = fromPartial<LoadContext>({
siteDir,
baseUrl: '/',
i18n: DefaultI18N,
localizationDir: path.join(siteDir, 'i18n/en'),
siteConfig: {
markdown: {
parseFrontMatter: DEFAULT_PARSE_FRONT_MATTER,
},
async function siteFixture(fixture: string) {
const siteDir = path.resolve(path.join(__dirname, './__fixtures__', fixture));
const options: PluginOptions = fromPartial<PluginOptions>({
id: 'default',
...DEFAULT_OPTIONS,
});
const context = fromPartial<LoadContext>({
siteDir,
baseUrl: '/',
i18n: DefaultI18N,
localizationDir: path.join(siteDir, 'i18n/en'),
siteConfig: {
markdown: {
parseFrontMatter: DEFAULT_PARSE_FRONT_MATTER,
},
},
});
const versions = await readVersionsMetadata({
options,
context,
});
return {
siteDir,
options,
context,
versions,
};
}
describe('loadVersion', () => {
describe('minimal site', () => {
it('can load current version', async () => {
const {options, context, versions} = await siteFixture('site-minimal');
const version = versions[0];
expect(version).toBeDefined();
expect(version.versionName).toBe('current');
const loadedVersion = loadVersion({
context,
options,
versionMetadata: version,
env: 'production',
});
await expect(loadedVersion).resolves.toMatchSnapshot();
});
return {
siteDir,
options,
context,
};
}
});
it('can load current version', async () => {
const {options, context} = await loadSite();
describe('site with broken versions', () => {
async function loadTestVersion(versionName: string) {
const {options, context, versions} = await siteFixture(
'site-broken-versions',
);
const version = versions.find((v) => v.versionName === versionName);
if (!version) {
throw new Error(`Version '${versionName}' should exist`);
}
return loadVersion({
context,
options,
versionMetadata: version,
env: 'production',
});
}
const versionsMetadata = await readVersionsMetadata({
options,
context,
it('rejects version with doc id conflict', async () => {
await expect(() => loadTestVersion('with-id-conflicts')).rejects
.toThrowErrorMatchingInlineSnapshot(`
"The docs plugin found docs sharing the same id:
- \`frontMatter/doc\` found in 3 docs:
- versioned_docs/version-with-id-conflicts/frontMatter/doc.md
- versioned_docs/version-with-id-conflicts/frontMatter/doc1.md
- versioned_docs/version-with-id-conflicts/frontMatter/doc2.md
- \`number-prefix/doc\` found in 2 docs:
- versioned_docs/version-with-id-conflicts/number-prefix/1-doc.md
- versioned_docs/version-with-id-conflicts/number-prefix/2-doc.md
- \`number-prefix/deeply/nested/doc\` found in 2 docs:
- versioned_docs/version-with-id-conflicts/number-prefix/deeply/nested/2-doc.md
- versioned_docs/version-with-id-conflicts/number-prefix/deeply/nested/3-doc.md
Docs should have distinct ids.
In case of conflict, you can rename the docs file, or use the \`id\` front matter to assign an explicit distinct id to each doc.
"
`);
});
expect(versionsMetadata).toHaveLength(1);
expect(versionsMetadata[0]!.versionName).toBe('current');
const versionMetadata = versionsMetadata[0]!;
const loadedVersion = loadVersion({
context,
options,
versionMetadata,
env: 'production',
});
await expect(loadedVersion).resolves.toMatchSnapshot();
});
});

View file

@ -7,7 +7,7 @@
import path from 'path';
import _ from 'lodash';
import {createSlugger} from '@docusaurus/utils';
import {aliasedSitePathToRelativePath, createSlugger} from '@docusaurus/utils';
import {getTagsFile} from '@docusaurus/utils-validation';
import logger from '@docusaurus/logger';
import {
@ -29,102 +29,151 @@ import type {
import type {DocFile} from '../types';
import type {LoadContext} from '@docusaurus/types';
export async function loadVersion({
context,
options,
versionMetadata,
env,
}: {
type LoadVersionParams = {
context: LoadContext;
options: PluginOptions;
versionMetadata: VersionMetadata;
env: DocEnv;
}): Promise<LoadedVersion> {
const {siteDir} = context;
};
async function loadVersionDocsBase(
tagsFile: TagsFile | null,
): Promise<DocMetadataBase[]> {
const docFiles = await readVersionDocs(versionMetadata, options);
if (docFiles.length === 0) {
throw new Error(
`Docs version "${
versionMetadata.versionName
}" has no docs! At least one doc should exist at "${path.relative(
siteDir,
versionMetadata.contentPath,
)}".`,
);
}
function processVersionDoc(docFile: DocFile) {
return processDocMetadata({
docFile,
versionMetadata,
context,
options,
env,
tagsFile,
});
}
return Promise.all(docFiles.map(processVersionDoc));
function ensureNoDuplicateDocId(docs: DocMetadataBase[]): void {
const duplicatesById = _.chain(docs)
.groupBy((d) => d.id)
.pickBy((group) => group.length > 1)
.value();
const duplicateIdEntries = Object.entries(duplicatesById);
if (duplicateIdEntries.length) {
const idMessages = duplicateIdEntries
.map(([id, duplicateDocs]) => {
return logger.interpolate`- code=${id} found in number=${
duplicateDocs.length
} docs:
- ${duplicateDocs
.map((d) => aliasedSitePathToRelativePath(d.source))
.join('\n - ')}`;
})
.join('\n\n');
const message = `The docs plugin found docs sharing the same id:
\n${idMessages}\n
Docs should have distinct ids.
In case of conflict, you can rename the docs file, or use the ${logger.code(
'id',
)} front matter to assign an explicit distinct id to each doc.
`;
throw new Error(message);
}
}
async function doLoadVersion(): Promise<LoadedVersion> {
const tagsFile = await getTagsFile({
contentPaths: versionMetadata,
tags: options.tags,
async function loadVersionDocsBase({
tagsFile,
context,
options,
versionMetadata,
env,
}: LoadVersionParams & {
tagsFile: TagsFile | null;
}): Promise<DocMetadataBase[]> {
const docFiles = await readVersionDocs(versionMetadata, options);
if (docFiles.length === 0) {
throw new Error(
`Docs version "${
versionMetadata.versionName
}" has no docs! At least one doc should exist at "${path.relative(
context.siteDir,
versionMetadata.contentPath,
)}".`,
);
}
function processVersionDoc(docFile: DocFile) {
return processDocMetadata({
docFile,
versionMetadata,
context,
options,
env,
tagsFile,
});
}
const docs = await Promise.all(docFiles.map(processVersionDoc));
ensureNoDuplicateDocId(docs);
return docs;
}
const docsBase: DocMetadataBase[] = await loadVersionDocsBase(tagsFile);
async function doLoadVersion({
context,
options,
versionMetadata,
env,
}: LoadVersionParams): Promise<LoadedVersion> {
const tagsFile = await getTagsFile({
contentPaths: versionMetadata,
tags: options.tags,
});
// TODO we only ever need draftIds in further code, not full draft items
// To simplify and prevent mistakes, avoid exposing draft
// replace draft=>draftIds in content loaded
const [drafts, docs] = _.partition(docsBase, (doc) => doc.draft);
const docsBase: DocMetadataBase[] = await loadVersionDocsBase({
tagsFile,
context,
options,
versionMetadata,
env,
});
const sidebars = await loadSidebars(versionMetadata.sidebarFilePath, {
sidebarItemsGenerator: options.sidebarItemsGenerator,
numberPrefixParser: options.numberPrefixParser,
// TODO we only ever need draftIds in further code, not full draft items
// To simplify and prevent mistakes, avoid exposing draft
// replace draft=>draftIds in content loaded
const [drafts, docs] = _.partition(docsBase, (doc) => doc.draft);
const sidebars = await loadSidebars(versionMetadata.sidebarFilePath, {
sidebarItemsGenerator: options.sidebarItemsGenerator,
numberPrefixParser: options.numberPrefixParser,
docs,
drafts,
version: versionMetadata,
sidebarOptions: {
sidebarCollapsed: options.sidebarCollapsed,
sidebarCollapsible: options.sidebarCollapsible,
},
categoryLabelSlugger: createSlugger(),
});
const sidebarsUtils = createSidebarsUtils(sidebars);
const docsById = createDocsByIdIndex(docs);
const allDocIds = Object.keys(docsById);
sidebarsUtils.checkLegacyVersionedSidebarNames({
sidebarFilePath: versionMetadata.sidebarFilePath as string,
versionMetadata,
});
sidebarsUtils.checkSidebarsDocIds({
allDocIds,
sidebarFilePath: versionMetadata.sidebarFilePath as string,
versionMetadata,
});
return {
...versionMetadata,
docs: addDocNavigation({
docs,
drafts,
version: versionMetadata,
sidebarOptions: {
sidebarCollapsed: options.sidebarCollapsed,
sidebarCollapsible: options.sidebarCollapsible,
},
categoryLabelSlugger: createSlugger(),
});
const sidebarsUtils = createSidebarsUtils(sidebars);
const docsById = createDocsByIdIndex(docs);
const allDocIds = Object.keys(docsById);
sidebarsUtils.checkLegacyVersionedSidebarNames({
sidebarFilePath: versionMetadata.sidebarFilePath as string,
versionMetadata,
});
sidebarsUtils.checkSidebarsDocIds({
allDocIds,
sidebarFilePath: versionMetadata.sidebarFilePath as string,
versionMetadata,
});
return {
...versionMetadata,
docs: addDocNavigation({
docs,
sidebarsUtils,
}),
drafts,
sidebars,
};
}
sidebarsUtils,
}),
drafts,
sidebars,
};
}
export async function loadVersion(
params: LoadVersionParams,
): Promise<LoadedVersion> {
try {
return await doLoadVersion();
return await doLoadVersion(params);
} catch (err) {
logger.error`Loading of version failed for version name=${versionMetadata.versionName}`;
// TODO use error cause (but need to refactor many tests)
logger.error`Loading of version failed for version name=${params.versionMetadata.versionName}`;
throw err;
}
}

View file

@ -0,0 +1,8 @@
---
id: file-name-1
slug: file-name-1
---
# File name 1
File name 1

View file

@ -0,0 +1,8 @@
---
id: file-name-2
slug: file-name-2
---
# File name 2
File name 2

View file

@ -0,0 +1,8 @@
{
"label": "File name conflict",
"link": {
"type": "generated-index",
"title": "File name conflict",
"description": "Testing what happens when 2 files have the same name but different position prefixes"
}
}