mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-10 23:02:56 +02:00
fix(docs): prevent docs ids conflicts within a version (#11251)
This commit is contained in:
parent
f811e2dbf4
commit
b54103be5d
22 changed files with 323 additions and 127 deletions
|
@ -0,0 +1,4 @@
|
|||
|
||||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: doc
|
||||
---
|
||||
|
||||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
# no id but should conflict due to the name anyway
|
||||
---
|
||||
|
||||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: doc
|
||||
---
|
||||
|
||||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: doc
|
||||
---
|
||||
|
||||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,3 @@
|
|||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: doc
|
||||
---
|
||||
|
||||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,3 @@
|
|||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,3 @@
|
|||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,3 @@
|
|||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: doc-1
|
||||
---
|
||||
|
||||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: doc-2
|
||||
---
|
||||
|
||||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: doc-3
|
||||
---
|
||||
|
||||
# Hello
|
||||
|
||||
World
|
|
@ -0,0 +1,3 @@
|
|||
[
|
||||
"with-id-conflicts"
|
||||
]
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
# Hello
|
||||
|
||||
World
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
id: file-name-1
|
||||
slug: file-name-1
|
||||
---
|
||||
|
||||
# File name 1
|
||||
|
||||
File name 1
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
id: file-name-2
|
||||
slug: file-name-2
|
||||
---
|
||||
|
||||
# File name 2
|
||||
|
||||
File name 2
|
|
@ -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"
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue