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 // 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, "badge": false,
"banner": null, "banner": null,
"className": "docs-version-current", "className": "docs-version-current",
"contentPath": "<PROJECT_ROOT>/packages/docusaurus-plugin-content-docs/src/versions/__tests__/__fixtures__/minimal-site/docs", "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__/minimal-site/i18n/en/docusaurus-plugin-content-docs/current", "contentPathLocalized": "<PROJECT_ROOT>/packages/docusaurus-plugin-content-docs/src/versions/__tests__/__fixtures__/site-minimal/i18n/en/docusaurus-plugin-content-docs/current",
"docs": [ "docs": [
{ {
"description": "World", "description": "World",

View file

@ -22,12 +22,10 @@ const DefaultI18N: I18n = {
localeConfigs: {}, localeConfigs: {},
}; };
describe('minimal site', () => { async function siteFixture(fixture: string) {
async function loadSite() { const siteDir = path.resolve(path.join(__dirname, './__fixtures__', fixture));
const siteDir = path.resolve(
path.join(__dirname, './__fixtures__', 'minimal-site'),
);
const options: PluginOptions = fromPartial<PluginOptions>({ const options: PluginOptions = fromPartial<PluginOptions>({
id: 'default',
...DEFAULT_OPTIONS, ...DEFAULT_OPTIONS,
}); });
const context = fromPartial<LoadContext>({ const context = fromPartial<LoadContext>({
@ -41,33 +39,79 @@ describe('minimal site', () => {
}, },
}, },
}); });
return {
siteDir,
options,
context,
};
}
it('can load current version', async () => { const versions = await readVersionsMetadata({
const {options, context} = await loadSite();
const versionsMetadata = await readVersionsMetadata({
options, options,
context, context,
}); });
expect(versionsMetadata).toHaveLength(1); return {
expect(versionsMetadata[0]!.versionName).toBe('current'); siteDir,
options,
context,
versions,
};
}
const versionMetadata = versionsMetadata[0]!; 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({ const loadedVersion = loadVersion({
context, context,
options, options,
versionMetadata, versionMetadata: version,
env: 'production', env: 'production',
}); });
await expect(loadedVersion).resolves.toMatchSnapshot(); await expect(loadedVersion).resolves.toMatchSnapshot();
}); });
}); });
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',
});
}
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.
"
`);
});
});
});

View file

@ -7,7 +7,7 @@
import path from 'path'; import path from 'path';
import _ from 'lodash'; import _ from 'lodash';
import {createSlugger} from '@docusaurus/utils'; import {aliasedSitePathToRelativePath, createSlugger} from '@docusaurus/utils';
import {getTagsFile} from '@docusaurus/utils-validation'; import {getTagsFile} from '@docusaurus/utils-validation';
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
import { import {
@ -29,29 +29,61 @@ import type {
import type {DocFile} from '../types'; import type {DocFile} from '../types';
import type {LoadContext} from '@docusaurus/types'; import type {LoadContext} from '@docusaurus/types';
export async function loadVersion({ type LoadVersionParams = {
context,
options,
versionMetadata,
env,
}: {
context: LoadContext; context: LoadContext;
options: PluginOptions; options: PluginOptions;
versionMetadata: VersionMetadata; versionMetadata: VersionMetadata;
env: DocEnv; env: DocEnv;
}): Promise<LoadedVersion> { };
const {siteDir} = context;
async function loadVersionDocsBase( function ensureNoDuplicateDocId(docs: DocMetadataBase[]): void {
tagsFile: TagsFile | null, const duplicatesById = _.chain(docs)
): Promise<DocMetadataBase[]> { .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 loadVersionDocsBase({
tagsFile,
context,
options,
versionMetadata,
env,
}: LoadVersionParams & {
tagsFile: TagsFile | null;
}): Promise<DocMetadataBase[]> {
const docFiles = await readVersionDocs(versionMetadata, options); const docFiles = await readVersionDocs(versionMetadata, options);
if (docFiles.length === 0) { if (docFiles.length === 0) {
throw new Error( throw new Error(
`Docs version "${ `Docs version "${
versionMetadata.versionName versionMetadata.versionName
}" has no docs! At least one doc should exist at "${path.relative( }" has no docs! At least one doc should exist at "${path.relative(
siteDir, context.siteDir,
versionMetadata.contentPath, versionMetadata.contentPath,
)}".`, )}".`,
); );
@ -66,16 +98,29 @@ export async function loadVersion({
tagsFile, tagsFile,
}); });
} }
return Promise.all(docFiles.map(processVersionDoc)); const docs = await Promise.all(docFiles.map(processVersionDoc));
ensureNoDuplicateDocId(docs);
return docs;
} }
async function doLoadVersion(): Promise<LoadedVersion> { async function doLoadVersion({
context,
options,
versionMetadata,
env,
}: LoadVersionParams): Promise<LoadedVersion> {
const tagsFile = await getTagsFile({ const tagsFile = await getTagsFile({
contentPaths: versionMetadata, contentPaths: versionMetadata,
tags: options.tags, tags: options.tags,
}); });
const docsBase: DocMetadataBase[] = await loadVersionDocsBase(tagsFile); const docsBase: DocMetadataBase[] = await loadVersionDocsBase({
tagsFile,
context,
options,
versionMetadata,
env,
});
// TODO we only ever need draftIds in further code, not full draft items // TODO we only ever need draftIds in further code, not full draft items
// To simplify and prevent mistakes, avoid exposing draft // To simplify and prevent mistakes, avoid exposing draft
@ -121,10 +166,14 @@ export async function loadVersion({
}; };
} }
export async function loadVersion(
params: LoadVersionParams,
): Promise<LoadedVersion> {
try { try {
return await doLoadVersion(); return await doLoadVersion(params);
} catch (err) { } 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; 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"
}
}