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', () => {
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"`);
});
});
});

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