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. * 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', () => { describe('toTagDocListProp', () => {
type Params = Parameters<typeof toTagDocListProp>[0]; type Params = Parameters<typeof toTagDocListProp>[0];
@ -132,3 +137,123 @@ describe('toSidebarDocItemLinkProp', () => {
).toBe(false); ).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, LoadedContent,
LoadedVersion, LoadedVersion,
} from '@docusaurus/plugin-content-docs'; } from '@docusaurus/plugin-content-docs';
import type {Sidebar} from '../sidebars/types';
function createSampleDoc(doc: Pick<DocMetadata, 'id'>): DocMetadata { function createSampleDoc(doc: Pick<DocMetadata, 'id'>): DocMetadata {
return { return {
@ -41,7 +42,7 @@ function createSampleDoc(doc: Pick<DocMetadata, 'id'>): DocMetadata {
} }
function createSampleVersion( function createSampleVersion(
version: Pick<LoadedVersion, 'versionName'>, version: Pick<LoadedVersion, 'versionName'> & Partial<LoadedVersion>,
): LoadedVersion { ): LoadedVersion {
return { return {
label: `${version.versionName} label`, label: `${version.versionName} label`,
@ -152,6 +153,150 @@ describe('getLoadedContentTranslationFiles', () => {
it('returns translation files', () => { it('returns translation files', () => {
expect(getSampleTranslationFiles()).toMatchSnapshot(); 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', () => { describe('translateLoadedContent', () => {

View file

@ -41,6 +41,7 @@ export function toSidebarDocItemLinkProp({
const {id, title, permalink, frontMatter, unlisted} = doc; const {id, title, permalink, frontMatter, unlisted} = doc;
return { return {
type: 'link', type: 'link',
...(item.key && {key: item.key}),
href: permalink, href: permalink,
// Front Matter data takes precedence over sidebars.json // Front Matter data takes precedence over sidebars.json
label: frontMatter.sidebar_label ?? item.label ?? title, 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); const docsById = createDocsByIdIndex(loadedVersion.docs);
function getDocById(docId: string): DocMetadata { function getDocById(docId: string): DocMetadata {

View file

@ -47,6 +47,7 @@ exports[`DefaultSidebarItemsGenerator generates complex nested sidebar 1`] = `
"type": "doc", "type": "doc",
}, },
], ],
"key": "SubGuides-category-unique-key",
"label": "SubGuides (metadata file label)", "label": "SubGuides (metadata file label)",
"link": { "link": {
"description": "subGuides-description", "description": "subGuides-description",

View file

@ -129,6 +129,7 @@ describe('DefaultSidebarItemsGenerator', () => {
}, },
'02-Guides/01-SubGuides': { '02-Guides/01-SubGuides': {
label: 'SubGuides (metadata file label)', label: 'SubGuides (metadata file label)',
key: 'SubGuides-category-unique-key',
link: { link: {
type: 'generated-index', type: 'generated-index',
slug: 'subGuides-generated-index-slug', slug: 'subGuides-generated-index-slug',

View file

@ -29,10 +29,12 @@ describe('validateSidebars', () => {
it('accept valid values', () => { it('accept valid values', () => {
const sidebars: SidebarsConfig = { const sidebars: SidebarsConfig = {
sidebar1: [ sidebar1: [
{type: 'doc', id: 'doc1'}, {type: 'doc', id: 'doc1', key: 'key-doc1'},
{type: 'doc', id: 'doc2'}, {type: 'doc', id: 'doc2'},
{type: 'ref', id: 'doc2', key: 'ref-doc2'},
{ {
type: 'category', type: 'category',
key: 'key-cat',
label: 'Category', label: 'Category',
items: [{type: 'doc', id: 'doc3'}], 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', () => { it('sidebars link wrong label', () => {
expect(() => expect(() =>
validateSidebars({ 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', () => { it('sidebars link wrong href', () => {
expect(() => expect(() =>
validateSidebars({ 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', () => { it('sidebar category wrong items', () => {
expect(() => expect(() =>
validateSidebars({ validateSidebars({
@ -230,8 +310,8 @@ describe('validateSidebars', () => {
const sidebars: SidebarsConfig = { const sidebars: SidebarsConfig = {
sidebar1: [ sidebar1: [
{ {
// @ts-expect-error - test missing value
type: 'html', type: 'html',
value: undefined,
}, },
], ],
}; };
@ -251,6 +331,7 @@ describe('validateSidebars', () => {
sidebar1: [ sidebar1: [
{ {
type: 'html', type: 'html',
key: 'html-key',
value: '<p>Hello, World!</p>', value: '<p>Hello, World!</p>',
defaultStyle: true, defaultStyle: true,
className: 'foo', className: 'foo',
@ -262,8 +343,6 @@ describe('validateSidebars', () => {
}); });
describe('validateCategoryMetadataFile', () => { describe('validateCategoryMetadataFile', () => {
// TODO add more tests
it('throw for bad value', () => { it('throw for bad value', () => {
expect(() => expect(() =>
validateCategoryMetadataFile(42), validateCategoryMetadataFile(42),
@ -279,6 +358,7 @@ describe('validateCategoryMetadataFile', () => {
const content: CategoryMetadataFile = { const content: CategoryMetadataFile = {
className: 'className', className: 'className',
label: 'Category Label', label: 'Category Label',
key: 'category-key',
description: 'Category Description', description: 'Category Description',
link: { link: {
type: 'generated-index', type: 'generated-index',
@ -293,24 +373,70 @@ describe('validateCategoryMetadataFile', () => {
expect(validateCategoryMetadataFile(content)).toEqual(content); expect(validateCategoryMetadataFile(content)).toEqual(content);
}); });
it('rejects permalink', () => { describe('label', () => {
const content: CategoryMetadataFile = { it('accepts valid label', () => {
className: 'className', const content: CategoryMetadataFile = {label: 'Category label'};
label: 'Category Label', expect(validateCategoryMetadataFile(content)).toEqual(content);
link: { });
type: 'generated-index',
slug: 'slug', it('throws for number label', () => {
// @ts-expect-error: rejected on purpose expect(() =>
permalink: 'somePermalink', validateCategoryMetadataFile({label: 42}),
title: 'title', ).toThrowErrorMatchingInlineSnapshot(`""label" must be a string"`);
description: 'description', });
}, });
collapsible: true,
collapsed: true, describe('key', () => {
position: 3, it('accepts valid key', () => {
}; const content: CategoryMetadataFile = {key: 'Category key'};
expect(() => expect(validateCategoryMetadataFile(content)).toEqual(content);
validateCategoryMetadataFile(content), });
).toThrowErrorMatchingInlineSnapshot(`""link.permalink" is not allowed"`);
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"`);
});
}); });
}); });

View file

@ -252,6 +252,7 @@ Available doc IDs:
...(categoryMetadata?.description && { ...(categoryMetadata?.description && {
description: categoryMetadata?.description, description: categoryMetadata?.description,
}), }),
...(categoryMetadata?.key && {key: categoryMetadata?.key}),
...(link && {link}), ...(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]}; type Expand<T extends {[x: string]: unknown}> = {[P in keyof T]: T[P]};
export type SidebarItemBase = { export type SidebarItemBase = {
key?: string;
className?: string; className?: string;
customProps?: {[key: string]: unknown}; customProps?: {[key: string]: unknown};
}; };
@ -28,8 +29,9 @@ export type SidebarItemDoc = SidebarItemBase & {
label?: string; label?: string;
id: string; id: string;
/** /**
* This is an internal marker. Items with labels defined in the config needs * This is an internal marker set during the sidebar normalization process.
* to be translated with JSON * 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; translatable?: true;
}; };
@ -215,6 +217,7 @@ export type PropSidebarBreadcrumbsItem =
| PropSidebarItemCategory; | PropSidebarItemCategory;
export type CategoryMetadataFile = { export type CategoryMetadataFile = {
key?: string;
label?: string; label?: string;
position?: number; position?: number;
description?: string; description?: string;

View file

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

View file

@ -7,6 +7,7 @@
import _ from 'lodash'; import _ from 'lodash';
import {mergeTranslations} from '@docusaurus/utils'; import {mergeTranslations} from '@docusaurus/utils';
import logger from '@docusaurus/logger';
import {CURRENT_VERSION_NAME} from './constants'; import {CURRENT_VERSION_NAME} from './constants';
import { import {
collectSidebarCategories, collectSidebarCategories,
@ -40,20 +41,53 @@ function getVersionFileName(versionName: string): string {
return `version-${versionName}`; 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( function getSidebarTranslationFileContent(
sidebar: Sidebar, sidebar: Sidebar,
sidebarName: string, sidebarName: string,
): TranslationFileContent { ): TranslationFileContent {
type TranslationMessageEntry = [string, TranslationMessage];
const categories = collectSidebarCategories(sidebar); const categories = collectSidebarCategories(sidebar);
const categoryContent: TranslationFileContent = Object.fromEntries( const categoryEntries: TranslationMessageEntry[] = categories.flatMap(
categories.flatMap((category) => { (category) => {
const entries: TranslationMessageEntry[] = []; const entries: TranslationMessageEntry[] = [];
const categoryKey = category.key ?? category.label;
entries.push([ entries.push([
`sidebar.${sidebarName}.category.${category.label}`, `sidebar.${sidebarName}.category.${categoryKey}`,
{ {
message: category.label, message: category.label,
description: `The label for category ${category.label} in sidebar ${sidebarName}`, 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?.type === 'generated-index') {
if (category.link.title) { if (category.link.title) {
entries.push([ entries.push([
`sidebar.${sidebarName}.category.${category.label}.link.generated-index.title`, `sidebar.${sidebarName}.category.${categoryKey}.link.generated-index.title`,
{ {
message: category.link.title, message: category.link.title,
description: `The generated-index page title for category ${category.label} in sidebar ${sidebarName}`, description: `The generated-index page title for category ${category.label} in sidebar ${sidebarName}`,
@ -72,7 +106,7 @@ function getSidebarTranslationFileContent(
} }
if (category.link.description) { if (category.link.description) {
entries.push([ entries.push([
`sidebar.${sidebarName}.category.${category.label}.link.generated-index.description`, `sidebar.${sidebarName}.category.${categoryKey}.link.generated-index.description`,
{ {
message: category.link.description, message: category.link.description,
description: `The generated-index page description for category ${category.label} in sidebar ${sidebarName}`, description: `The generated-index page description for category ${category.label} in sidebar ${sidebarName}`,
@ -82,36 +116,40 @@ function getSidebarTranslationFileContent(
} }
return entries; return entries;
}), },
); );
const links = collectSidebarLinks(sidebar); const links = collectSidebarLinks(sidebar);
const linksContent: TranslationFileContent = Object.fromEntries( const linksEntries: TranslationMessageEntry[] = links.map((link) => {
links.map((link) => [ const linkKey = link.key ?? link.label;
`sidebar.${sidebarName}.link.${link.label}`, return [
`sidebar.${sidebarName}.link.${linkKey}`,
{ {
message: link.label, message: link.label,
description: `The label for link ${link.label} in sidebar ${sidebarName}, linking to ${link.href}`, description: `The label for link ${link.label} in sidebar ${sidebarName}, linking to ${link.href}`,
}, },
]), ];
); });
const docs = collectSidebarDocItems(sidebar) const docs = collectSidebarDocItems(sidebar)
.concat(collectSidebarRefs(sidebar)) .concat(collectSidebarRefs(sidebar))
.filter((item) => item.translatable); .filter((item) => item.translatable);
const docLinksContent: TranslationFileContent = Object.fromEntries( const docLinksEntries: TranslationMessageEntry[] = docs.map((doc) => {
docs.map((doc) => [ const docKey = doc.key ?? doc.label!;
`sidebar.${sidebarName}.doc.${doc.label!}`, return [
`sidebar.${sidebarName}.doc.${docKey}`,
{ {
message: doc.label!, message: doc.label!,
description: `The label for the doc item ${doc.label!} in sidebar ${sidebarName}, linking to the doc ${ description: `The label for the doc item ${doc.label!} in sidebar ${sidebarName}, linking to the doc ${
doc.id doc.id
}`, }`,
}, },
]), ];
); });
return mergeTranslations([categoryContent, linksContent, docLinksContent]); const allEntries = [...categoryEntries, ...linksEntries, ...docLinksEntries];
ensureNoSidebarDuplicateEntries(allEntries);
return Object.fromEntries(allEntries);
} }
function translateSidebar({ function translateSidebar({
@ -150,27 +188,30 @@ function translateSidebar({
return transformSidebarItems(sidebar, (item) => { return transformSidebarItems(sidebar, (item) => {
if (item.type === 'category') { if (item.type === 'category') {
const link = transformSidebarCategoryLink(item); const link = transformSidebarCategoryLink(item);
const categoryKey = item.key ?? item.label;
return { return {
...item, ...item,
label: label:
sidebarsTranslations[`sidebar.${sidebarName}.category.${item.label}`] sidebarsTranslations[`sidebar.${sidebarName}.category.${categoryKey}`]
?.message ?? item.label, ?.message ?? item.label,
...(link && {link}), ...(link && {link}),
}; };
} }
if (item.type === 'link') { if (item.type === 'link') {
const linkKey = item.key ?? item.label;
return { return {
...item, ...item,
label: label:
sidebarsTranslations[`sidebar.${sidebarName}.link.${item.label}`] sidebarsTranslations[`sidebar.${sidebarName}.link.${linkKey}`]
?.message ?? item.label, ?.message ?? item.label,
}; };
} }
if ((item.type === 'doc' || item.type === 'ref') && item.translatable) { if ((item.type === 'doc' || item.type === 'ref') && item.translatable) {
const docKey = item.key ?? item.label!;
return { return {
...item, ...item,
label: label:
sidebarsTranslations[`sidebar.${sidebarName}.doc.${item.label!}`] sidebarsTranslations[`sidebar.${sidebarName}.doc.${docKey}`]
?.message ?? item.label, ?.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').SidebarsConfig} SidebarsConfig
* @typedef {import('@docusaurus/plugin-content-docs/lib/sidebars/types').SidebarItemConfig} SidebarItemConfig * @typedef {import('@docusaurus/plugin-content-docs/lib/sidebars/types').SidebarItemConfig} SidebarItemConfig
* @typedef {import('@docusaurus/plugin-content-docs/lib/sidebars/types').SidebarItemCategoryConfig} SidebarItemCategoryConfig
*/ */
/** @type {SidebarsConfig} */ /** @type {SidebarsConfig} */
@ -63,7 +64,8 @@ const sidebars = {
items: [ items: [
{ {
type: 'link', type: 'link',
label: 'Link ', label: 'Link',
key: 'link-key-1',
href: 'https://docusaurus.io', href: 'https://docusaurus.io',
}, },
], ],
@ -76,6 +78,7 @@ const sidebars = {
{ {
type: 'link', type: 'link',
label: 'Link ', label: 'Link ',
key: 'link-key-2',
href: 'https://docusaurus.io', href: 'https://docusaurus.io',
}, },
], ],
@ -182,14 +185,16 @@ function generateHugeSidebarItems() {
/** /**
* @param {number} maxLevel * @param {number} maxLevel
* @param {number} currentLevel * @param {number} currentLevel
* @param {string} parentKey
* @returns {SidebarItemConfig[]} * @returns {SidebarItemConfig[]}
*/ */
function generateRecursive(maxLevel, currentLevel = 0) { function generateRecursive(maxLevel, currentLevel = 0, parentKey = 'ROOT') {
if (currentLevel === maxLevel) { if (currentLevel === maxLevel) {
return [ return [
{ {
type: 'link', type: 'link',
href: '/', href: '/',
key: `link-${parentKey}-maxLevel`,
label: `Link (level ${currentLevel + 1})`, label: `Link (level ${currentLevel + 1})`,
}, },
]; ];
@ -198,14 +203,23 @@ function generateHugeSidebarItems() {
const linkItems = Array.from(Array(linksCount).keys()).map((index) => ({ const linkItems = Array.from(Array(linksCount).keys()).map((index) => ({
type: 'link', type: 'link',
href: '/', href: '/',
key: `link-${parentKey}-${index}`,
label: `Link ${index} (level ${currentLevel + 1})`, label: `Link ${index} (level ${currentLevel + 1})`,
})); }));
const categoryItems = Array.from(Array(categoriesCount).keys()).map( const categoryItems = Array.from(Array(categoriesCount).keys()).map(
/**
* @returns {SidebarItemCategoryConfig}
*/
(index) => ({ (index) => ({
type: 'category', type: 'category',
label: `Category ${index} (level ${currentLevel + 1})`, 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: Creating a sidebar is useful to:
- Group multiple **related documents** - Group multiple **related documents** into an ordered tree
- **Display a sidebar** on each of those documents - **Display a common sidebar** on each of those documents
- Provide **paginated navigation**, with next/previous button - Provide **paginated navigation**, with next/previous button
To use sidebars on your Docusaurus site: 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} ## 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. 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} ## Sidebar Breadcrumbs {#sidebar-breadcrumbs}
By default, breadcrumbs are rendered at the top, using the "sidebar path" of the current page. 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'; 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 - **[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 - **[Link](#sidebar-item-link)**: link to any internal or external page
- **[Category](#sidebar-item-category)**: creates a dropdown of sidebar items - **[Category](#sidebar-item-category)**: creates a dropdown of sidebar items
- **[Autogenerated](autogenerated.mdx)**: generate a sidebar slice automatically - **[Autogenerated](autogenerated.mdx)**: generate a sidebar slice automatically
- **[HTML](#sidebar-item-html)**: renders pure HTML in the item's position - **[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} ## Doc: link to a doc {#sidebar-item-doc}
@ -31,6 +31,7 @@ type SidebarItemDoc =
type: 'doc'; type: 'doc';
id: string; id: string;
label: string; // Sidebar label text label: string; // Sidebar label text
key?: string; // Sidebar key to uniquely identify the item
className?: string; // Class name for sidebar label className?: string; // Class name for sidebar label
customProps?: Record<string, unknown>; // Custom props customProps?: Record<string, unknown>; // Custom props
} }
@ -84,8 +85,10 @@ type SidebarItemLink = {
type: 'link'; type: 'link';
label: string; label: string;
href: string; href: string;
className?: string;
description?: string; description?: string;
key?: string;
className?: string;
customProps?: Record<string, unknown>;
}; };
``` ```
@ -126,7 +129,9 @@ type SidebarItemHtml = {
type: 'html'; type: 'html';
value: string; value: string;
defaultStyle?: boolean; // Use default menu item styles defaultStyle?: boolean; // Use default menu item styles
key?: string;
className?: string; className?: string;
customProps?: Record<string, unknown>;
}; };
``` ```
@ -173,8 +178,10 @@ type SidebarItemCategory = {
type: 'category'; type: 'category';
label: string; // Sidebar label text. label: string; // Sidebar label text.
items: SidebarItem[]; // Array of sidebar items. items: SidebarItem[]; // Array of sidebar items.
className?: string;
description?: string; description?: string;
key?: string;
className?: string;
customProps?: Record<string, unknown>;
// Category options: // Category options:
collapsible: boolean; // Set the category to be collapsible collapsible: boolean; // Set the category to be collapsible