diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/props.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/props.test.ts index 960727f01f..09f2ca20b4 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/props.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/props.test.ts @@ -5,7 +5,12 @@ * LICENSE file in the root directory of this source tree. */ -import {toSidebarDocItemLinkProp, toTagDocListProp} from '../props'; +import {fromPartial} from '@total-typescript/shoehorn'; +import { + toSidebarDocItemLinkProp, + toSidebarsProp, + toTagDocListProp, +} from '../props'; describe('toTagDocListProp', () => { type Params = Parameters[0]; @@ -132,3 +137,123 @@ describe('toSidebarDocItemLinkProp', () => { ).toBe(false); }); }); + +describe('toSidebarsProp', () => { + type Params = Parameters[0]; + + it('works', () => { + const params: Params = { + docs: [ + fromPartial({ + id: 'doc-id-1', + permalink: '/doc-1', + title: 'Doc 1 title', + frontMatter: {}, + }), + ], + sidebars: { + mySidebar: [ + { + type: 'link', + label: 'Example link', + key: 'link-example-key', + href: 'https://example.com', + }, + { + type: 'ref', + label: 'Doc 1 ref', + key: 'ref-with-doc-id-1', + id: 'doc-id-1', + }, + { + type: 'ref', + id: 'doc-id-1', + // no label/key on purpose + }, + { + type: 'category', + label: 'My category', + key: 'my-category-key', + collapsible: false, + collapsed: true, + items: [ + { + type: 'doc', + label: 'Doc 1', + key: 'doc-id-1', + id: 'doc-id-1', + }, + { + type: 'doc', + id: 'doc-id-1', + // no label/key on purpose + }, + ], + }, + ], + }, + }; + + const result = toSidebarsProp(params); + + expect(result).toMatchInlineSnapshot(` + { + "mySidebar": [ + { + "href": "https://example.com", + "key": "link-example-key", + "label": "Example link", + "type": "link", + }, + { + "className": undefined, + "customProps": undefined, + "docId": "doc-id-1", + "href": "/doc-1", + "key": "ref-with-doc-id-1", + "label": "Doc 1 ref", + "type": "link", + "unlisted": undefined, + }, + { + "className": undefined, + "customProps": undefined, + "docId": "doc-id-1", + "href": "/doc-1", + "label": "Doc 1 title", + "type": "link", + "unlisted": undefined, + }, + { + "collapsed": true, + "collapsible": false, + "items": [ + { + "className": undefined, + "customProps": undefined, + "docId": "doc-id-1", + "href": "/doc-1", + "key": "doc-id-1", + "label": "Doc 1", + "type": "link", + "unlisted": undefined, + }, + { + "className": undefined, + "customProps": undefined, + "docId": "doc-id-1", + "href": "/doc-1", + "label": "Doc 1 title", + "type": "link", + "unlisted": undefined, + }, + ], + "key": "my-category-key", + "label": "My category", + "type": "category", + }, + ], + } + `); + }); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/translations.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/translations.test.ts index 6175606158..45a06c114d 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/translations.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/translations.test.ts @@ -16,6 +16,7 @@ import type { LoadedContent, LoadedVersion, } from '@docusaurus/plugin-content-docs'; +import type {Sidebar} from '../sidebars/types'; function createSampleDoc(doc: Pick): DocMetadata { return { @@ -41,7 +42,7 @@ function createSampleDoc(doc: Pick): DocMetadata { } function createSampleVersion( - version: Pick, + version: Pick & Partial, ): LoadedVersion { return { label: `${version.versionName} label`, @@ -152,6 +153,150 @@ describe('getLoadedContentTranslationFiles', () => { it('returns translation files', () => { expect(getSampleTranslationFiles()).toMatchSnapshot(); }); + + describe('translation key conflicts', () => { + function runTest({withUniqueKeys}: {withUniqueKeys: boolean}) { + const sidebarWithConflicts: Sidebar = [ + { + type: 'doc', + id: 'doc4', + label: 'COMMON LABEL', + translatable: true, + ...(withUniqueKeys && {key: 'key-doc4'}), + }, + { + type: 'doc', + id: 'doc5', + label: 'COMMON LABEL', + translatable: true, + ...(withUniqueKeys && {key: 'key-doc5'}), + }, + { + type: 'ref', + id: 'doc4', + label: 'COMMON LABEL', + translatable: true, + ...(withUniqueKeys && {key: 'key-ref4'}), + }, + { + type: 'ref', + id: 'doc5', + label: 'COMMON LABEL', + translatable: true, + ...(withUniqueKeys && {key: 'key-ref5'}), + }, + { + type: 'category', + label: 'COMMON LABEL', + items: [], + collapsed: false, + collapsible: true, + ...(withUniqueKeys && {key: 'key-cat1'}), + }, + { + type: 'category', + label: 'COMMON LABEL', + items: [], + collapsed: false, + collapsible: true, + ...(withUniqueKeys && {key: 'key-cat2'}), + }, + { + type: 'link', + href: 'https://example.com', + label: 'COMMON LABEL', + ...(withUniqueKeys && {key: 'key-link1'}), + }, + { + type: 'link', + href: 'https://example.com', + label: 'COMMON LABEL', + ...(withUniqueKeys && {key: 'key-link2'}), + }, + ]; + + const version = createSampleVersion({ + versionName: CURRENT_VERSION_NAME, + sidebars: { + sidebarWithConflicts, + }, + }); + return getLoadedContentTranslationFiles({ + loadedVersions: [version], + }); + } + + it('works on sidebar with translation key conflicts resolved by unique sidebar item keys', () => { + expect(runTest({withUniqueKeys: true})).toMatchInlineSnapshot(` + [ + { + "content": { + "sidebar.sidebarWithConflicts.category.key-cat1": { + "description": "The label for category COMMON LABEL in sidebar sidebarWithConflicts", + "message": "COMMON LABEL", + }, + "sidebar.sidebarWithConflicts.category.key-cat2": { + "description": "The label for category COMMON LABEL in sidebar sidebarWithConflicts", + "message": "COMMON LABEL", + }, + "sidebar.sidebarWithConflicts.doc.key-doc4": { + "description": "The label for the doc item COMMON LABEL in sidebar sidebarWithConflicts, linking to the doc doc4", + "message": "COMMON LABEL", + }, + "sidebar.sidebarWithConflicts.doc.key-doc5": { + "description": "The label for the doc item COMMON LABEL in sidebar sidebarWithConflicts, linking to the doc doc5", + "message": "COMMON LABEL", + }, + "sidebar.sidebarWithConflicts.doc.key-ref4": { + "description": "The label for the doc item COMMON LABEL in sidebar sidebarWithConflicts, linking to the doc doc4", + "message": "COMMON LABEL", + }, + "sidebar.sidebarWithConflicts.doc.key-ref5": { + "description": "The label for the doc item COMMON LABEL in sidebar sidebarWithConflicts, linking to the doc doc5", + "message": "COMMON LABEL", + }, + "sidebar.sidebarWithConflicts.link.key-link1": { + "description": "The label for link COMMON LABEL in sidebar sidebarWithConflicts, linking to https://example.com", + "message": "COMMON LABEL", + }, + "sidebar.sidebarWithConflicts.link.key-link2": { + "description": "The label for link COMMON LABEL in sidebar sidebarWithConflicts, linking to https://example.com", + "message": "COMMON LABEL", + }, + "version.label": { + "description": "The label for version current", + "message": "current label", + }, + }, + "path": "current", + }, + ] + `); + }); + + it('throws on sidebar translation key conflicts', () => { + expect(() => runTest({withUniqueKeys: false})) + .toThrowErrorMatchingInlineSnapshot(` + "Multiple docs sidebar items produce the same translation key. + - \`sidebar.sidebarWithConflicts.category.COMMON LABEL\`: 2 duplicates found: + - COMMON LABEL (The label for category COMMON LABEL in sidebar sidebarWithConflicts) + - COMMON LABEL (The label for category COMMON LABEL in sidebar sidebarWithConflicts) + + - \`sidebar.sidebarWithConflicts.link.COMMON LABEL\`: 2 duplicates found: + - COMMON LABEL (The label for link COMMON LABEL in sidebar sidebarWithConflicts, linking to https://example.com) + - COMMON LABEL (The label for link COMMON LABEL in sidebar sidebarWithConflicts, linking to https://example.com) + + - \`sidebar.sidebarWithConflicts.doc.COMMON LABEL\`: 4 duplicates found: + - COMMON LABEL (The label for the doc item COMMON LABEL in sidebar sidebarWithConflicts, linking to the doc doc4) + - COMMON LABEL (The label for the doc item COMMON LABEL in sidebar sidebarWithConflicts, linking to the doc doc5) + - COMMON LABEL (The label for the doc item COMMON LABEL in sidebar sidebarWithConflicts, linking to the doc doc4) + - COMMON LABEL (The label for the doc item COMMON LABEL in sidebar sidebarWithConflicts, linking to the doc doc5) + + To avoid translation key conflicts, use the \`key\` attribute on the sidebar items above to uniquely identify them. + " + `); + }); + }); }); describe('translateLoadedContent', () => { diff --git a/packages/docusaurus-plugin-content-docs/src/props.ts b/packages/docusaurus-plugin-content-docs/src/props.ts index 3f2d00c87c..c7a2eb1040 100644 --- a/packages/docusaurus-plugin-content-docs/src/props.ts +++ b/packages/docusaurus-plugin-content-docs/src/props.ts @@ -41,6 +41,7 @@ export function toSidebarDocItemLinkProp({ const {id, title, permalink, frontMatter, unlisted} = doc; return { type: 'link', + ...(item.key && {key: item.key}), href: permalink, // Front Matter data takes precedence over sidebars.json label: frontMatter.sidebar_label ?? item.label ?? title, @@ -51,7 +52,9 @@ export function toSidebarDocItemLinkProp({ }; } -export function toSidebarsProp(loadedVersion: LoadedVersion): PropSidebars { +export function toSidebarsProp( + loadedVersion: Pick, +): PropSidebars { const docsById = createDocsByIdIndex(loadedVersion.docs); function getDocById(docId: string): DocMetadata { diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__snapshots__/generator.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__snapshots__/generator.test.ts.snap index 63430fd363..e4d7132e65 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__snapshots__/generator.test.ts.snap +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__snapshots__/generator.test.ts.snap @@ -47,6 +47,7 @@ exports[`DefaultSidebarItemsGenerator generates complex nested sidebar 1`] = ` "type": "doc", }, ], + "key": "SubGuides-category-unique-key", "label": "SubGuides (metadata file label)", "link": { "description": "subGuides-description", diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/generator.test.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/generator.test.ts index de104a1a25..5072fd80d7 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/generator.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/generator.test.ts @@ -129,6 +129,7 @@ describe('DefaultSidebarItemsGenerator', () => { }, '02-Guides/01-SubGuides': { label: 'SubGuides (metadata file label)', + key: 'SubGuides-category-unique-key', link: { type: 'generated-index', slug: 'subGuides-generated-index-slug', diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/validation.test.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/validation.test.ts index 47055ffce7..042c95d34a 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/validation.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/validation.test.ts @@ -29,10 +29,12 @@ describe('validateSidebars', () => { it('accept valid values', () => { const sidebars: SidebarsConfig = { sidebar1: [ - {type: 'doc', id: 'doc1'}, + {type: 'doc', id: 'doc1', key: 'key-doc1'}, {type: 'doc', id: 'doc2'}, + {type: 'ref', id: 'doc2', key: 'ref-doc2'}, { type: 'category', + key: 'key-cat', label: 'Category', items: [{type: 'doc', id: 'doc3'}], }, @@ -68,6 +70,33 @@ describe('validateSidebars', () => { `); }); + it('sidebar category wrong key', () => { + expect(() => + validateSidebars({ + docs: [ + { + type: 'category', + key: '', + items: [{type: 'doc', id: 'doc1'}], + }, + ], + }), + ).toThrowErrorMatchingInlineSnapshot(` + "{ + "type": "category", + "items": [ + { + "type": "doc", + "id": "doc1" + } + ], + "key" [1]: "" + } + + [1] "key" is not allowed to be empty" + `); + }); + it('sidebars link wrong label', () => { expect(() => validateSidebars({ @@ -90,6 +119,28 @@ describe('validateSidebars', () => { `); }); + it('sidebars link wrong key', () => { + expect(() => + validateSidebars({ + docs: [ + { + type: 'link', + key: false, + href: 'https://github.com', + }, + ], + }), + ).toThrowErrorMatchingInlineSnapshot(` + "{ + "type": "link", + "href": "https://github.com", + "key" [1]: false + } + + [1] "key" must be a string" + `); + }); + it('sidebars link wrong href', () => { expect(() => validateSidebars({ @@ -188,6 +239,35 @@ describe('validateSidebars', () => { `); }); + it('sidebars category wrong key', () => { + expect(() => + validateSidebars({ + docs: [ + { + type: 'category', + label: 'category', + key: 42, + items: [], + }, + + { + type: 'ref', + id: 'hello', + }, + ], + }), + ).toThrowErrorMatchingInlineSnapshot(` + "{ + "type": "category", + "label": "category", + "items": [], + "key" [1]: 42 + } + + [1] "key" must be a string" + `); + }); + it('sidebar category wrong items', () => { expect(() => validateSidebars({ @@ -230,8 +310,8 @@ describe('validateSidebars', () => { const sidebars: SidebarsConfig = { sidebar1: [ { - // @ts-expect-error - test missing value type: 'html', + value: undefined, }, ], }; @@ -251,6 +331,7 @@ describe('validateSidebars', () => { sidebar1: [ { type: 'html', + key: 'html-key', value: '

Hello, World!

', defaultStyle: true, className: 'foo', @@ -262,8 +343,6 @@ describe('validateSidebars', () => { }); describe('validateCategoryMetadataFile', () => { - // TODO add more tests - it('throw for bad value', () => { expect(() => validateCategoryMetadataFile(42), @@ -279,6 +358,7 @@ describe('validateCategoryMetadataFile', () => { const content: CategoryMetadataFile = { className: 'className', label: 'Category Label', + key: 'category-key', description: 'Category Description', link: { type: 'generated-index', @@ -293,24 +373,70 @@ describe('validateCategoryMetadataFile', () => { expect(validateCategoryMetadataFile(content)).toEqual(content); }); - it('rejects permalink', () => { - const content: CategoryMetadataFile = { - className: 'className', - label: 'Category Label', - link: { - type: 'generated-index', - slug: 'slug', - // @ts-expect-error: rejected on purpose - permalink: 'somePermalink', - title: 'title', - description: 'description', - }, - collapsible: true, - collapsed: true, - position: 3, - }; - expect(() => - validateCategoryMetadataFile(content), - ).toThrowErrorMatchingInlineSnapshot(`""link.permalink" is not allowed"`); + describe('label', () => { + it('accepts valid label', () => { + const content: CategoryMetadataFile = {label: 'Category label'}; + expect(validateCategoryMetadataFile(content)).toEqual(content); + }); + + it('throws for number label', () => { + expect(() => + validateCategoryMetadataFile({label: 42}), + ).toThrowErrorMatchingInlineSnapshot(`""label" must be a string"`); + }); + }); + + describe('key', () => { + it('accepts valid key', () => { + const content: CategoryMetadataFile = {key: 'Category key'}; + expect(validateCategoryMetadataFile(content)).toEqual(content); + }); + + it('throws for number key', () => { + expect(() => + validateCategoryMetadataFile({key: 42}), + ).toThrowErrorMatchingInlineSnapshot(`""key" must be a string"`); + }); + }); + + describe('className', () => { + it('accepts valid className', () => { + const content: CategoryMetadataFile = {className: 'category-className'}; + expect(validateCategoryMetadataFile(content)).toEqual(content); + }); + + it('throws for number key', () => { + expect(() => + validateCategoryMetadataFile({className: 42}), + ).toThrowErrorMatchingInlineSnapshot(`""className" must be a string"`); + }); + }); + + describe('link', () => { + it('accepts valid link', () => { + const content: CategoryMetadataFile = { + link: { + type: 'generated-index', + slug: 'slug', + title: 'title', + description: 'desc', + }, + }; + expect(validateCategoryMetadataFile(content)).toEqual(content); + }); + + it('rejects link permalink', () => { + const content: CategoryMetadataFile = { + link: { + type: 'generated-index', + slug: 'slug', + // @ts-expect-error: rejected on purpose + permalink: 'somePermalink', + }, + }; + expect(() => + validateCategoryMetadataFile(content), + ).toThrowErrorMatchingInlineSnapshot(`""link.permalink" is not allowed"`); + }); }); }); diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/generator.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/generator.ts index 20383b003a..cbd58d7b5b 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/generator.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/generator.ts @@ -252,6 +252,7 @@ Available doc IDs: ...(categoryMetadata?.description && { description: categoryMetadata?.description, }), + ...(categoryMetadata?.key && {key: categoryMetadata?.key}), ...(link && {link}), }; } diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts index 9dbd23415d..550243b90e 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts @@ -19,6 +19,7 @@ import type {Slugger} from '@docusaurus/utils'; type Expand = {[P in keyof T]: T[P]}; export type SidebarItemBase = { + key?: string; className?: string; customProps?: {[key: string]: unknown}; }; @@ -28,8 +29,9 @@ export type SidebarItemDoc = SidebarItemBase & { label?: string; id: string; /** - * This is an internal marker. Items with labels defined in the config needs - * to be translated with JSON + * This is an internal marker set during the sidebar normalization process. + * Docs with labels defined in the config need to be translated with JSON. + * Otherwise, it's preferable to translate the MDX doc title or front matter. */ translatable?: true; }; @@ -215,6 +217,7 @@ export type PropSidebarBreadcrumbsItem = | PropSidebarItemCategory; export type CategoryMetadataFile = { + key?: string; label?: string; position?: number; description?: string; diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/validation.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/validation.ts index 33492dc309..bc1ca47061 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/validation.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/validation.ts @@ -28,6 +28,7 @@ import type { // in normalization const sidebarItemBaseSchema = Joi.object({ + key: Joi.string(), className: Joi.string(), customProps: Joi.object().unknown(), }); @@ -166,6 +167,7 @@ export function validateSidebars(sidebars: { } const categoryMetadataFileSchema = Joi.object({ + key: Joi.string(), label: Joi.string(), description: Joi.string(), position: Joi.number(), diff --git a/packages/docusaurus-plugin-content-docs/src/translations.ts b/packages/docusaurus-plugin-content-docs/src/translations.ts index bc6f8bbb8b..ec35afbe4b 100644 --- a/packages/docusaurus-plugin-content-docs/src/translations.ts +++ b/packages/docusaurus-plugin-content-docs/src/translations.ts @@ -7,6 +7,7 @@ import _ from 'lodash'; import {mergeTranslations} from '@docusaurus/utils'; +import logger from '@docusaurus/logger'; import {CURRENT_VERSION_NAME} from './constants'; import { collectSidebarCategories, @@ -40,20 +41,53 @@ function getVersionFileName(versionName: string): string { return `version-${versionName}`; } +type TranslationMessageEntry = [string, TranslationMessage]; + +function ensureNoSidebarDuplicateEntries( + translationEntries: TranslationMessageEntry[], +): void { + const grouped = _.groupBy(translationEntries, (entry) => entry[0]); + const duplicates = Object.entries(grouped).filter( + (entry) => entry[1].length > 1, + ); + + if (duplicates.length > 0) { + throw new Error(`Multiple docs sidebar items produce the same translation key. +- ${duplicates + .map(([translationKey, entries]) => { + return `${logger.code(translationKey)}: ${logger.num( + entries.length, + )} duplicates found:\n - ${entries + .map((duplicate) => { + const desc = duplicate[1].description; + return `${logger.name(duplicate[1].message)} ${ + desc ? `(${logger.subdue(desc)})` : '' + }`; + }) + .join('\n - ')}`; + }) + .join('\n\n- ')} + +To avoid translation key conflicts, use the ${logger.code( + 'key', + )} attribute on the sidebar items above to uniquely identify them. + `); + } +} + function getSidebarTranslationFileContent( sidebar: Sidebar, sidebarName: string, ): TranslationFileContent { - type TranslationMessageEntry = [string, TranslationMessage]; - const categories = collectSidebarCategories(sidebar); - const categoryContent: TranslationFileContent = Object.fromEntries( - categories.flatMap((category) => { + const categoryEntries: TranslationMessageEntry[] = categories.flatMap( + (category) => { const entries: TranslationMessageEntry[] = []; + const categoryKey = category.key ?? category.label; entries.push([ - `sidebar.${sidebarName}.category.${category.label}`, + `sidebar.${sidebarName}.category.${categoryKey}`, { message: category.label, description: `The label for category ${category.label} in sidebar ${sidebarName}`, @@ -63,7 +97,7 @@ function getSidebarTranslationFileContent( if (category.link?.type === 'generated-index') { if (category.link.title) { entries.push([ - `sidebar.${sidebarName}.category.${category.label}.link.generated-index.title`, + `sidebar.${sidebarName}.category.${categoryKey}.link.generated-index.title`, { message: category.link.title, description: `The generated-index page title for category ${category.label} in sidebar ${sidebarName}`, @@ -72,7 +106,7 @@ function getSidebarTranslationFileContent( } if (category.link.description) { entries.push([ - `sidebar.${sidebarName}.category.${category.label}.link.generated-index.description`, + `sidebar.${sidebarName}.category.${categoryKey}.link.generated-index.description`, { message: category.link.description, description: `The generated-index page description for category ${category.label} in sidebar ${sidebarName}`, @@ -82,36 +116,40 @@ function getSidebarTranslationFileContent( } return entries; - }), + }, ); const links = collectSidebarLinks(sidebar); - const linksContent: TranslationFileContent = Object.fromEntries( - links.map((link) => [ - `sidebar.${sidebarName}.link.${link.label}`, + const linksEntries: TranslationMessageEntry[] = links.map((link) => { + const linkKey = link.key ?? link.label; + return [ + `sidebar.${sidebarName}.link.${linkKey}`, { message: link.label, description: `The label for link ${link.label} in sidebar ${sidebarName}, linking to ${link.href}`, }, - ]), - ); + ]; + }); const docs = collectSidebarDocItems(sidebar) .concat(collectSidebarRefs(sidebar)) .filter((item) => item.translatable); - const docLinksContent: TranslationFileContent = Object.fromEntries( - docs.map((doc) => [ - `sidebar.${sidebarName}.doc.${doc.label!}`, + const docLinksEntries: TranslationMessageEntry[] = docs.map((doc) => { + const docKey = doc.key ?? doc.label!; + return [ + `sidebar.${sidebarName}.doc.${docKey}`, { message: doc.label!, description: `The label for the doc item ${doc.label!} in sidebar ${sidebarName}, linking to the doc ${ doc.id }`, }, - ]), - ); + ]; + }); - return mergeTranslations([categoryContent, linksContent, docLinksContent]); + const allEntries = [...categoryEntries, ...linksEntries, ...docLinksEntries]; + ensureNoSidebarDuplicateEntries(allEntries); + return Object.fromEntries(allEntries); } function translateSidebar({ @@ -150,27 +188,30 @@ function translateSidebar({ return transformSidebarItems(sidebar, (item) => { if (item.type === 'category') { const link = transformSidebarCategoryLink(item); + const categoryKey = item.key ?? item.label; return { ...item, label: - sidebarsTranslations[`sidebar.${sidebarName}.category.${item.label}`] + sidebarsTranslations[`sidebar.${sidebarName}.category.${categoryKey}`] ?.message ?? item.label, ...(link && {link}), }; } if (item.type === 'link') { + const linkKey = item.key ?? item.label; return { ...item, label: - sidebarsTranslations[`sidebar.${sidebarName}.link.${item.label}`] + sidebarsTranslations[`sidebar.${sidebarName}.link.${linkKey}`] ?.message ?? item.label, }; } if ((item.type === 'doc' || item.type === 'ref') && item.translatable) { + const docKey = item.key ?? item.label!; return { ...item, label: - sidebarsTranslations[`sidebar.${sidebarName}.doc.${item.label!}`] + sidebarsTranslations[`sidebar.${sidebarName}.doc.${docKey}`] ?.message ?? item.label, }; } diff --git a/website/_dogfooding/_docs tests/tests/Conflicts/_category_.json b/website/_dogfooding/_docs tests/tests/Conflicts/_category_.json new file mode 100644 index 0000000000..0a0e8395de --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/Conflicts/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Conflicts", + "link": { + "type": "generated-index", + "title": "Conflicts", + "description": "Testing what happens when docs use similar names" + } +} diff --git a/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/_category_.json b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/_category_.json new file mode 100644 index 0000000000..ab5ce9583f --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Category Index name conflict", + "link": { + "type": "generated-index", + "title": "Category Index name conflict", + "description": "Testing what happens when 2 category index have the same name" + } +} diff --git a/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/alpha/_category_.json b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/alpha/_category_.json new file mode 100644 index 0000000000..a1689a31d9 --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/alpha/_category_.json @@ -0,0 +1,6 @@ +{ + "label": "Alpha", + "link": { + "type": "generated-index" + } +} diff --git a/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/alpha/test/_category_.json b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/alpha/test/_category_.json new file mode 100644 index 0000000000..dd99960965 --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/alpha/test/_category_.json @@ -0,0 +1,9 @@ +{ + "key": "CategoryLabelTest alpha", + "label": "CategoryLabelTest", + "link": { + "type": "generated-index", + "title": "test category in Alpha", + "description": "Test description in Alpha" + } +} diff --git a/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/alpha/test/test.md b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/alpha/test/test.md new file mode 100644 index 0000000000..4ee22315f8 --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/alpha/test/test.md @@ -0,0 +1,4 @@ +--- +--- + +## Test file diff --git a/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/beta/_category_.json b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/beta/_category_.json new file mode 100644 index 0000000000..86eedaae9f --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/beta/_category_.json @@ -0,0 +1,6 @@ +{ + "label": "Beta", + "link": { + "type": "generated-index" + } +} diff --git a/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/beta/test/_category_.json b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/beta/test/_category_.json new file mode 100644 index 0000000000..e0fad183c6 --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/beta/test/_category_.json @@ -0,0 +1,9 @@ +{ + "key": "CategoryLabelTest beta", + "label": "CategoryLabelTest", + "link": { + "type": "generated-index", + "title": "test category in Beta", + "description": "Test description in Beta" + } +} diff --git a/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/beta/test/test.md b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/beta/test/test.md new file mode 100644 index 0000000000..4ee22315f8 --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/Conflicts/category-index-name/beta/test/test.md @@ -0,0 +1,4 @@ +--- +--- + +## Test file diff --git a/website/_dogfooding/_docs tests/tests/Conflicts/sidebar-label/doc1.mdx b/website/_dogfooding/_docs tests/tests/Conflicts/sidebar-label/doc1.mdx new file mode 100644 index 0000000000..b4dd575602 --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/Conflicts/sidebar-label/doc1.mdx @@ -0,0 +1,7 @@ +--- +sidebar_label: Doc sidebar label +--- + +# Doc 1 + +Doc 1 diff --git a/website/_dogfooding/_docs tests/tests/Conflicts/sidebar-label/doc2.mdx b/website/_dogfooding/_docs tests/tests/Conflicts/sidebar-label/doc2.mdx new file mode 100644 index 0000000000..3bb0f6b84e --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/Conflicts/sidebar-label/doc2.mdx @@ -0,0 +1,7 @@ +--- +sidebar_label: Doc sidebar label +--- + +# Doc 2 + +Doc 2 diff --git a/website/_dogfooding/docs-tests-sidebars.js b/website/_dogfooding/docs-tests-sidebars.js index 67d4b80300..d8dac0c10e 100644 --- a/website/_dogfooding/docs-tests-sidebars.js +++ b/website/_dogfooding/docs-tests-sidebars.js @@ -8,6 +8,7 @@ /** * @typedef {import('@docusaurus/plugin-content-docs').SidebarsConfig} SidebarsConfig * @typedef {import('@docusaurus/plugin-content-docs/lib/sidebars/types').SidebarItemConfig} SidebarItemConfig + * @typedef {import('@docusaurus/plugin-content-docs/lib/sidebars/types').SidebarItemCategoryConfig} SidebarItemCategoryConfig */ /** @type {SidebarsConfig} */ @@ -63,7 +64,8 @@ const sidebars = { items: [ { type: 'link', - label: 'Link ', + label: 'Link', + key: 'link-key-1', href: 'https://docusaurus.io', }, ], @@ -76,6 +78,7 @@ const sidebars = { { type: 'link', label: 'Link ', + key: 'link-key-2', href: 'https://docusaurus.io', }, ], @@ -182,14 +185,16 @@ function generateHugeSidebarItems() { /** * @param {number} maxLevel * @param {number} currentLevel + * @param {string} parentKey * @returns {SidebarItemConfig[]} */ - function generateRecursive(maxLevel, currentLevel = 0) { + function generateRecursive(maxLevel, currentLevel = 0, parentKey = 'ROOT') { if (currentLevel === maxLevel) { return [ { type: 'link', href: '/', + key: `link-${parentKey}-maxLevel`, label: `Link (level ${currentLevel + 1})`, }, ]; @@ -198,14 +203,23 @@ function generateHugeSidebarItems() { const linkItems = Array.from(Array(linksCount).keys()).map((index) => ({ type: 'link', href: '/', + key: `link-${parentKey}-${index}`, label: `Link ${index} (level ${currentLevel + 1})`, })); const categoryItems = Array.from(Array(categoriesCount).keys()).map( + /** + * @returns {SidebarItemCategoryConfig} + */ (index) => ({ type: 'category', label: `Category ${index} (level ${currentLevel + 1})`, - items: generateRecursive(maxLevel, currentLevel + 1), + key: `category-${parentKey}-${index}`, + items: generateRecursive( + maxLevel, + currentLevel + 1, + `${parentKey}-${index}`, + ), }), ); diff --git a/website/docs/guides/docs/sidebar/index.mdx b/website/docs/guides/docs/sidebar/index.mdx index 4d2c4d209b..307d836fb3 100644 --- a/website/docs/guides/docs/sidebar/index.mdx +++ b/website/docs/guides/docs/sidebar/index.mdx @@ -6,8 +6,8 @@ slug: /sidebar Creating a sidebar is useful to: -- Group multiple **related documents** -- **Display a sidebar** on each of those documents +- Group multiple **related documents** into an ordered tree +- **Display a common sidebar** on each of those documents - Provide **paginated navigation**, with next/previous button To use sidebars on your Docusaurus site: @@ -160,6 +160,20 @@ export default { }; ``` +## Passing CSS classes {#passing-css-classes} + +To pass CSS classes to a sidebar item, add the optional `className` attribute to any of the items. This is useful to apply visual customizations to specific sidebar items. + +```js +{ + type: 'doc', + id: 'doc1', + // highlight-start + className: 'sidebar-item--highlighted', + // highlight-end +}; +``` + ## Passing custom props {#passing-custom-props} To pass in custom props to a sidebar item, add the optional `customProps` object to any of the items. This is useful to apply site customizations by swizzling React components rendering sidebar items. @@ -177,6 +191,28 @@ To pass in custom props to a sidebar item, add the optional `customProps` object }; ``` +## Passing a unique key {#passing-custom-props} + +Passing a unique `key` attribute can help uniquely identify a sidebar item. Sometimes other attributes (such as `label`) are not enough to distinguish two sidebar items from each other. + +```js +{ + type: 'category', + // highlight-start + label: 'API', // You may have multiple categories with this widespread label + key: 'api-for-feature-1', // and now, they can be uniquely identified + // highlight-end +}; +``` + +:::info How is this useful? + +Docusaurus only uses the `key` attribute to generate unique i18n translation keys. When a translation key conflict happens ([issue](https://github.com/facebook/docusaurus/issues/10913)), Docusaurus will tell you to apply a `key` to distinguish sidebar items. + +Alternatively, you may have your own reasons for using the `key` attribute that will be passed to the respective sidebar item React components. + +::: + ## Sidebar Breadcrumbs {#sidebar-breadcrumbs} By default, breadcrumbs are rendered at the top, using the "sidebar path" of the current page. diff --git a/website/docs/guides/docs/sidebar/items.mdx b/website/docs/guides/docs/sidebar/items.mdx index 1dd0c0100e..12c4a518ee 100644 --- a/website/docs/guides/docs/sidebar/items.mdx +++ b/website/docs/guides/docs/sidebar/items.mdx @@ -11,14 +11,14 @@ import TabItem from '@theme/TabItem'; import BrowserWindow from '@site/src/components/BrowserWindow'; ``` -We have introduced three types of item types in the example in the previous section: `doc`, `category`, and `link`, whose usages are fairly intuitive. We will formally introduce their APIs. There's also a fourth type: `autogenerated`, which we will explain in detail later. +The sidebar supports various item types: - **[Doc](#sidebar-item-doc)**: link to a doc page, associating it with the sidebar - **[Link](#sidebar-item-link)**: link to any internal or external page - **[Category](#sidebar-item-category)**: creates a dropdown of sidebar items - **[Autogenerated](autogenerated.mdx)**: generate a sidebar slice automatically - **[HTML](#sidebar-item-html)**: renders pure HTML in the item's position -- **[\*Ref](multiple-sidebars.mdx#sidebar-item-ref)**: link to a doc page, without making the item take part in navigation generation +- **[Ref](multiple-sidebars.mdx#sidebar-item-ref)**: link to a doc page, without making the item take part in navigation generation ## Doc: link to a doc {#sidebar-item-doc} @@ -31,6 +31,7 @@ type SidebarItemDoc = type: 'doc'; id: string; label: string; // Sidebar label text + key?: string; // Sidebar key to uniquely identify the item className?: string; // Class name for sidebar label customProps?: Record; // Custom props } @@ -84,8 +85,10 @@ type SidebarItemLink = { type: 'link'; label: string; href: string; - className?: string; description?: string; + key?: string; + className?: string; + customProps?: Record; }; ``` @@ -126,7 +129,9 @@ type SidebarItemHtml = { type: 'html'; value: string; defaultStyle?: boolean; // Use default menu item styles + key?: string; className?: string; + customProps?: Record; }; ``` @@ -173,8 +178,10 @@ type SidebarItemCategory = { type: 'category'; label: string; // Sidebar label text. items: SidebarItem[]; // Array of sidebar items. - className?: string; description?: string; + key?: string; + className?: string; + customProps?: Record; // Category options: collapsible: boolean; // Set the category to be collapsible