feat(docs): sidebar item key attribute - fix docs translations key conflicts (#11228)

This commit is contained in:
Sébastien Lorber 2025-07-03 13:40:00 +02:00 committed by GitHub
parent d9d7e855c2
commit da08536816
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 632 additions and 59 deletions

View file

@ -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<typeof toTagDocListProp>[0];
@ -132,3 +137,123 @@ describe('toSidebarDocItemLinkProp', () => {
).toBe(false);
});
});
describe('toSidebarsProp', () => {
type Params = Parameters<typeof toSidebarsProp>[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",
},
],
}
`);
});
});

View file

@ -16,6 +16,7 @@ import type {
LoadedContent,
LoadedVersion,
} from '@docusaurus/plugin-content-docs';
import type {Sidebar} from '../sidebars/types';
function createSampleDoc(doc: Pick<DocMetadata, 'id'>): DocMetadata {
return {
@ -41,7 +42,7 @@ function createSampleDoc(doc: Pick<DocMetadata, 'id'>): DocMetadata {
}
function createSampleVersion(
version: Pick<LoadedVersion, 'versionName'>,
version: Pick<LoadedVersion, 'versionName'> & Partial<LoadedVersion>,
): 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', () => {

View file

@ -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<LoadedVersion, 'docs' | 'sidebars'>,
): PropSidebars {
const docsById = createDocsByIdIndex(loadedVersion.docs);
function getDocById(docId: string): DocMetadata {

View file

@ -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",

View file

@ -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',

View file

@ -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: '<p>Hello, World!</p>',
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', () => {
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 = {
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"`);
});
});
});

View file

@ -252,6 +252,7 @@ Available doc IDs:
...(categoryMetadata?.description && {
description: categoryMetadata?.description,
}),
...(categoryMetadata?.key && {key: categoryMetadata?.key}),
...(link && {link}),
};
}

View file

@ -19,6 +19,7 @@ import type {Slugger} from '@docusaurus/utils';
type Expand<T extends {[x: string]: unknown}> = {[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;

View file

@ -28,6 +28,7 @@ import type {
// in normalization
const sidebarItemBaseSchema = Joi.object<SidebarItemBase>({
key: Joi.string(),
className: Joi.string(),
customProps: Joi.object().unknown(),
});
@ -166,6 +167,7 @@ export function validateSidebars(sidebars: {
}
const categoryMetadataFileSchema = Joi.object<CategoryMetadataFile>({
key: Joi.string(),
label: Joi.string(),
description: Joi.string(),
position: Joi.number(),

View file

@ -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,
};
}

View file

@ -0,0 +1,8 @@
{
"label": "Conflicts",
"link": {
"type": "generated-index",
"title": "Conflicts",
"description": "Testing what happens when docs use similar names"
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1,6 @@
{
"label": "Alpha",
"link": {
"type": "generated-index"
}
}

View file

@ -0,0 +1,9 @@
{
"key": "CategoryLabelTest alpha",
"label": "CategoryLabelTest",
"link": {
"type": "generated-index",
"title": "test category in Alpha",
"description": "Test description in Alpha"
}
}

View file

@ -0,0 +1,4 @@
---
---
## Test file

View file

@ -0,0 +1,6 @@
{
"label": "Beta",
"link": {
"type": "generated-index"
}
}

View file

@ -0,0 +1,9 @@
{
"key": "CategoryLabelTest beta",
"label": "CategoryLabelTest",
"link": {
"type": "generated-index",
"title": "test category in Beta",
"description": "Test description in Beta"
}
}

View file

@ -0,0 +1,4 @@
---
---
## Test file

View file

@ -0,0 +1,7 @@
---
sidebar_label: Doc sidebar label
---
# Doc 1
Doc 1

View file

@ -0,0 +1,7 @@
---
sidebar_label: Doc sidebar label
---
# Doc 2
Doc 2

View file

@ -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}`,
),
}),
);

View file

@ -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.

View file

@ -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<string, unknown>; // Custom props
}
@ -84,8 +85,10 @@ type SidebarItemLink = {
type: 'link';
label: string;
href: string;
className?: string;
description?: string;
key?: string;
className?: string;
customProps?: Record<string, unknown>;
};
```
@ -126,7 +129,9 @@ type SidebarItemHtml = {
type: 'html';
value: string;
defaultStyle?: boolean; // Use default menu item styles
key?: string;
className?: string;
customProps?: Record<string, unknown>;
};
```
@ -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<string, unknown>;
// Category options:
collapsible: boolean; // Set the category to be collapsible