diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/index.test.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/index.test.ts index 6ff5994..44c5bf1 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/index.test.ts @@ -1,189 +1,280 @@ /** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. + * Tests for sidebars/index.ts + * Framework: Jest (TypeScript). These tests mock fs, globby, yaml, and internal processors. */ -import {jest} from '@jest/globals'; import path from 'path'; -import {createSlugger} from '@docusaurus/utils'; -import {loadSidebars, DisabledSidebars} from '../index'; -import {DefaultSidebarItemsGenerator} from '../generator'; -import type {SidebarProcessorParams} from '../types'; -import type { - DocMetadata, - VersionMetadata, -} from '@docusaurus/plugin-content-docs'; - -describe('loadSidebars', () => { - const fixtureDir = path.join(__dirname, '__fixtures__', 'sidebars'); - const params: SidebarProcessorParams = { - sidebarItemsGenerator: DefaultSidebarItemsGenerator, - numberPrefixParser: (filename) => ({filename}), - docs: [ - { - source: '@site/docs/foo/bar.md', - sourceDirName: 'foo', - id: 'bar', - frontMatter: {}, - }, - ] as DocMetadata[], - drafts: [], - version: { - path: 'version', - contentPath: path.join(fixtureDir, 'docs'), - contentPathLocalized: path.join(fixtureDir, 'docs'), - } as VersionMetadata, - categoryLabelSlugger: {slug: (v) => v}, - sidebarOptions: {sidebarCollapsed: true, sidebarCollapsible: true}, - }; - it('sidebars with known sidebar item type', async () => { - const sidebarPath = path.join(fixtureDir, 'sidebars.json'); - const result = await loadSidebars(sidebarPath, params); - expect(result).toMatchSnapshot(); + +// System under test +import * as SidebarsMod from '../index'; + +// Mocks +jest.mock('fs-extra', () => ({ + __esModule: true, + default: { + readFile: jest.fn(), + pathExists: jest.fn(), + }, +})); +import fs from 'fs-extra'; + +jest.mock('@docusaurus/utils', () => ({ + __esModule: true, + loadFreshModule: jest.fn(), + Globby: jest.fn(), +})); +import {loadFreshModule, Globby} from '@docusaurus/utils'; + +jest.mock('js-yaml', () => ({ + __esModule: true, + default: {}, + load: jest.fn(), +})); +import Yaml from 'js-yaml'; + +jest.mock('combine-promises', () => ({ + __esModule: true, + default: (obj: Record) => + Promise.all( + Object.entries(obj).map(async ([k, v]) => [k, await v]), + ).then((entries) => Object.fromEntries(entries)), +})); +import combinePromises from 'combine-promises'; + +jest.mock('../validation', () => ({ + __esModule: true, + validateSidebars: jest.fn(), + validateCategoryMetadataFile: jest.fn((v) => v), +})); +import {validateSidebars, validateCategoryMetadataFile} from '../validation'; + +jest.mock('../normalization', () => ({ + __esModule: true, + normalizeSidebars: jest.fn((s) => s), +})); +import {normalizeSidebars} from '../normalization'; + +jest.mock('../processor', () => ({ + __esModule: true, + processSidebars: jest.fn((_norm, _catMeta, _opts) => ({ + processed: true, + input: _norm, + meta: _catMeta, + })), +})); +import {processSidebars} from '../processor'; + +jest.mock('../postProcessor', () => ({ + __esModule: true, + postProcessSidebars: jest.fn((processed, _opts) => ({ + ...processed, + postProcessed: true, + })), +})); +import {postProcessSidebars} from '../postProcessor'; + +jest.mock('@docusaurus/logger', () => ({ + __esModule: true, + default: { + warn: jest.fn(), // tag function usage compatible + error: jest.fn(), // tag function usage compatible + }, +})); +import logger from '@docusaurus/logger'; + +// Helpers to access constants +const {DefaultSidebars, DisabledSidebars} = SidebarsMod; + +describe('resolveSidebarPathOption', () => { + test('returns absolute path when given a relative string', () => { + const siteDir = '/var/www/site'; + const result = SidebarsMod.resolveSidebarPathOption(siteDir, 'sidebars.js'); + expect(result).toBe(path.resolve(siteDir, 'sidebars.js')); }); - it('sidebars with some draft items', async () => { - const sidebarPath = path.join(fixtureDir, 'sidebars.json'); - const paramsWithDrafts: SidebarProcessorParams = { - ...params, - drafts: [{id: 'foo/baz'} as DocMetadata, {id: 'hello'} as DocMetadata], - }; - const result = await loadSidebars(sidebarPath, paramsWithDrafts); - expect(result).toMatchSnapshot(); + test('passes through false unchanged', () => { + const result = SidebarsMod.resolveSidebarPathOption('/x', false); + expect(result).toBe(false); }); - it('sidebars with deep level of category', async () => { - const sidebarPath = path.join(fixtureDir, 'sidebars-category.js'); - const result = await loadSidebars(sidebarPath, params); - expect(result).toMatchSnapshot(); + test('passes through undefined unchanged', () => { + const result = SidebarsMod.resolveSidebarPathOption('/x', undefined); + expect(result).toBeUndefined(); }); +}); - it('sidebars shorthand and longhand lead to exact same sidebar', async () => { - const sidebarPath1 = path.join(fixtureDir, 'sidebars-category.js'); - const sidebarPath2 = path.join( - fixtureDir, - 'sidebars-category-shorthand.js', - ); - const sidebar1 = await loadSidebars(sidebarPath1, params); - const sidebar2 = await loadSidebars(sidebarPath2, params); - expect(sidebar1).toEqual(sidebar2); +describe('loadSidebarsFile', () => { + beforeEach(() => { + jest.resetAllMocks(); }); - it('sidebars with category but category.items is not an array', async () => { - const sidebarPath = path.join( - fixtureDir, - 'sidebars-category-wrong-items.json', - ); - await expect(() => - loadSidebars(sidebarPath, params), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Invalid sidebar items collection \`"doc1"\` in \`items\` of the category Category Label: it must either be an array of sidebar items or a shorthand notation (which doesn't contain a \`type\` property). See https://docusaurus.io/docs/sidebar/items for all valid syntaxes."`, - ); + test('returns DisabledSidebars when option is false', async () => { + const res = await SidebarsMod.loadSidebarsFile(false); + expect(res).toEqual(DisabledSidebars); }); - it('sidebars with first level not a category', async () => { - const sidebarPath = path.join( - fixtureDir, - 'sidebars-first-level-not-category.js', - ); - const result = await loadSidebars(sidebarPath, params); - expect(result).toMatchSnapshot(); + test('returns DefaultSidebars when option is undefined', async () => { + const res = await SidebarsMod.loadSidebarsFile(undefined); + expect(res).toEqual(DefaultSidebars); }); - it('sidebars link', async () => { - const sidebarPath = path.join(fixtureDir, 'sidebars-link.json'); - const result = await loadSidebars(sidebarPath, params); - expect(result).toMatchSnapshot(); + test('returns DisabledSidebars when file does not exist', async () => { + (fs.pathExists as jest.Mock).mockResolvedValue(false); + const res = await SidebarsMod['loadSidebarsFile']?.('/abs/missing/sidebars.js' as any); + // The public wrapper delegates to unsafe; behavior for non-existent should be disabled sidebars. + expect(res).toEqual(DisabledSidebars); + expect(fs.pathExists).toHaveBeenCalledWith('/abs/missing/sidebars.js'); }); - it('nonexistent path', async () => { - await expect(loadSidebars('bad/path', params)).resolves.toEqual( - DisabledSidebars, - ); + test('loads module via loadFreshModule when file exists', async () => { + const fakeConfig = {mySidebar: [{type: 'doc', id: 'intro'}]}; + (fs.pathExists as jest.Mock).mockResolvedValue(true); + (loadFreshModule as jest.Mock).mockResolvedValue(fakeConfig); + + const res = await SidebarsMod.loadSidebarsFile('/abs/path/sidebars.js' as any); + expect(fs.pathExists).toHaveBeenCalledWith('/abs/path/sidebars.js'); + expect(loadFreshModule).toHaveBeenCalledWith('/abs/path/sidebars.js'); + expect(res).toEqual(fakeConfig); }); +}); - it('undefined path', async () => { - await expect(loadSidebars(undefined, params)).resolves.toMatchSnapshot(); +describe('loadSidebars (integration across pipeline)', () => { + const baseOptions = { + version: { + contentPath: '/docs', + }, + } as any; + + beforeEach(() => { + jest.resetAllMocks(); + (Globby as jest.Mock).mockResolvedValue([]); + (fs.pathExists as jest.Mock).mockResolvedValue(false); + (Yaml.load as jest.Mock).mockReset(); }); - it('literal false path', async () => { - await expect(loadSidebars(false, params)).resolves.toEqual( - DisabledSidebars, + test('happy path: uses defaults when sidebarFilePath undefined and runs full pipeline', async () => { + // Arrange readCategoriesMetadata to find one category file per folder + (Globby as jest.Mock).mockResolvedValue([ + 'a/_category_.json', + 'b/_category_.yml', + ]); + + // Mock file reads for both files + (fs.readFile as jest.Mock).mockImplementation(async (p: string) => { + if (p.endsWith('a/_category_.json')) { + return JSON.stringify({label: 'A', position: 1}); + } + if (p.endsWith('b/_category_.yml')) { + return 'label: B\nposition: 2'; + } + throw new Error('Unexpected readFile path: ' + p); + }); + + // Yaml.load should only be needed for yml/yaml + (Yaml.load as jest.Mock).mockImplementation((content: string) => { + if (content.includes('label: B')) { + return {label: 'B', position: 2}; + } + return JSON.parse(content); + }); + + // Act + const result = await SidebarsMod.loadSidebars(undefined, baseOptions); + + // Assert pipeline calls + expect(normalizeSidebars).toHaveBeenCalledWith(DefaultSidebars); + expect(validateSidebars).toHaveBeenCalled(); + + // Validate categories metadata was combined and passed to processor + expect(processSidebars).toHaveBeenCalledWith( + DefaultSidebars, + { + '/docs/a': {label: 'A', position: 1}, + '/docs/b': {label: 'B', position: 2}, + }, + baseOptions, ); + + expect(postProcessSidebars).toHaveBeenCalled(); + expect(result).toEqual({ + processed: true, + input: DefaultSidebars, + meta: { + '/docs/a': {label: 'A', position: 1}, + '/docs/b': {label: 'B', position: 2}, + }, + postProcessed: true, + }); + + // No error should be logged + expect((logger as any).error).not.toHaveBeenCalled(); }); - it('sidebars with category.collapsed property', async () => { - const sidebarPath = path.join(fixtureDir, 'sidebars-collapsed.json'); - const result = await loadSidebars(sidebarPath, params); - expect(result).toMatchSnapshot(); + test('warns when multiple category metadata files exist for the same folder', async () => { + (Globby as jest.Mock).mockResolvedValue([ + 'a/_category_.json', + 'a/_category_.yml', + ]); + (fs.readFile as jest.Mock).mockResolvedValue('{}'); + (Yaml.load as jest.Mock).mockImplementation((c: string) => { + try { + return JSON.parse(c); + } catch { + return {}; + } + }); + + await SidebarsMod.loadSidebars(undefined, baseOptions); + + expect((logger as any).warn).toHaveBeenCalled(); }); - it('sidebars with category.collapsed property at first level', async () => { - const sidebarPath = path.join( - fixtureDir, - 'sidebars-collapsed-first-level.json', - ); - const result = await loadSidebars(sidebarPath, params); - expect(result).toMatchSnapshot(); + test('logs and rethrows when category metadata file is invalid', async () => { + (Globby as jest.Mock).mockResolvedValue(['a/_category_.yml']); + (fs.readFile as jest.Mock).mockResolvedValue('label: "Bad": "YAML"'); // malformed + (Yaml.load as jest.Mock).mockImplementation(() => { + throw new Error('YAML parse error'); + }); + + await expect( + SidebarsMod.loadSidebars(undefined, baseOptions), + ).rejects.toThrow('YAML parse error'); + + // It should log specific category metadata error, then the general sidebars load error + expect((logger as any).error).toHaveBeenCalled(); + const calls = (logger as any).error.mock.calls; + // First error from readCategoriesMetadata + expect(calls.some((args: any[]) => (args?.[0]?.raw || args?.[0])?.toString?.().includes('category metadata file'))).toBe(true); }); - it('loads sidebars with index-only categories', async () => { - const sidebarPath = path.join(fixtureDir, 'sidebars-category-index.json'); - const result = await loadSidebars(sidebarPath, { - ...params, - docs: [ - { - id: 'tutorials/tutorial-basics', - source: '@site/docs/tutorials/tutorial-basics/index.md', - sourceDirName: 'tutorials/tutorial-basics', - frontMatter: {}, - }, - ] as DocMetadata[], + test('logs and rethrows when normalizeSidebars fails', async () => { + (normalizeSidebars as jest.Mock).mockImplementation(() => { + throw new Error('normalize failed'); }); - expect(result).toMatchSnapshot(); + + await expect( + SidebarsMod.loadSidebars(undefined, baseOptions), + ).rejects.toThrow('normalize failed'); + + expect((logger as any).error).toHaveBeenCalled(); + // The error message should mention the sidebars file path (undefined in this case) + const errLogCalled = (logger as any).error.mock.calls.some((args: any[]) => { + const first = args?.[0]; + const text = (first?.raw || first)?.toString?.() ?? ''; + return text.includes('Sidebars file at path=undefined'); + }); + expect(errLogCalled).toBe(true); }); +}); - it('loads sidebars with interspersed draft items', async () => { - const sidebarPath = path.join(fixtureDir, 'sidebars-drafts.json'); - const result = await loadSidebars(sidebarPath, { - ...params, - drafts: [{id: 'draft1'}, {id: 'draft2'}, {id: 'draft3'}] as DocMetadata[], - categoryLabelSlugger: createSlugger(), +describe('Default and Disabled sidebars constants', () => { + test('DefaultSidebars contains autogenerated at root', () => { + expect(DefaultSidebars).toEqual({ + defaultSidebar: [{type: 'autogenerated', dirName: '.'}], }); - expect(result).toMatchSnapshot(); }); - it('duplicate category metadata files', async () => { - const sidebarPath = path.join( - fixtureDir, - 'sidebars-collapsed-first-level.json', - ); - const consoleWarnMock = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); - const consoleErrorMock = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - await expect(() => - loadSidebars(sidebarPath, { - ...params, - version: { - contentPath: path.join(fixtureDir, 'invalid-docs'), - contentPathLocalized: path.join(fixtureDir, 'invalid-docs'), - } as VersionMetadata, - }), - ).rejects.toThrowErrorMatchingInlineSnapshot(`""foo" is not allowed"`); - expect(consoleWarnMock).toHaveBeenCalledWith( - expect.stringMatching( - /.*\[WARNING\].* There are more than one category metadata files for .*foo.*: foo\/_category_.json, foo\/_category_.yml. The behavior is undetermined./, - ), - ); - expect(consoleErrorMock).toHaveBeenCalledWith( - expect.stringMatching( - /.*\[ERROR\].* The docs sidebar category metadata file .*foo\/_category_.json.* looks invalid!/, - ), - ); + test('DisabledSidebars is an empty object', () => { + expect(DisabledSidebars).toEqual({}); }); }); diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/utils.test.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/utils.test.ts new file mode 100644 index 0000000..6621d27 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/utils.test.ts @@ -0,0 +1,502 @@ +/** + * Tests for sidebars utils + * Framework: Jest + */ + +import * as Utils from './utils'; + +// Local helpers to construct sidebar items with minimal shapes. +// We intentionally cast as any to avoid depending on internal type packages. +const doc = (id: string, label?: string): any => ({ type: 'doc', id, label }); +const ref = (id: string, label?: string): any => ({ type: 'ref', id, label }); +const link = (label: string, href: string): any => ({ type: 'link', href, label }); +const category = ( + label: string, + items: any[], + linkObj?: any +): any => ({ type: 'category', label, items, ...(linkObj ? { link: linkObj } : {}) }); + +describe('isCategoriesShorthand', () => { + test('returns true for plain object without "type" key', () => { + expect(Utils.isCategoriesShorthand({ foo: 'bar' } as any)).toBe(true); + expect(Utils.isCategoriesShorthand({ label: 'X', items: [] } as any)).toBe(true); + }); + + test('returns false for objects with "type"', () => { + expect(Utils.isCategoriesShorthand(doc('a'))).toBe(false); + expect(Utils.isCategoriesShorthand(category('C', []))).toBe(false); + }); + + test('returns false for non-objects', () => { + expect(Utils.isCategoriesShorthand('string' as any)).toBe(false); + expect(Utils.isCategoriesShorthand(42 as any)).toBe(false); + expect(Utils.isCategoriesShorthand(null as any)).toBe(false); + expect(Utils.isCategoriesShorthand(undefined as any)).toBe(false); + }); +}); + +describe('transformSidebarItems', () => { + test('recursively applies updateFn to all items while preserving structure', () => { + const sidebar: any[] = [ + doc('a', 'A'), + category('Cat', [doc('b', 'B'), category('Sub', [doc('c', 'C')])]), + link('External', 'https://example.com'), + ]; + + const seen: string[] = []; + const updated = Utils.transformSidebarItems(sidebar, (item) => { + // Record visitation order by type/id/label to ensure every node is visited once + if ((item as any).type === 'doc') { + seen.push(`doc:${(item as any).id}`); + return { ...item, label: `${item.label ?? item.id} (v)` }; + } + if ((item as any).type === 'category') { + seen.push(`category:${(item as any).label}`); + return { ...item, label: `${(item as any).label} (v)` }; + } + if ((item as any).type === 'link') { + seen.push(`link:${(item as any).label}`); + return { ...item, label: `${(item as any).label} (v)` }; + } + return item; + }); + + expect(seen).toEqual([ + 'doc:a', + 'category:Cat', + 'doc:b', + 'category:Sub', + 'doc:c', + 'link:External', + ]); + + // Verify deep structure preserved and labels updated + expect(updated).toMatchObject([ + { type: 'doc', id: 'a', label: 'A (v)' }, + { + type: 'category', + label: 'Cat (v)', + items: [ + { type: 'doc', id: 'b', label: 'B (v)' }, + { + type: 'category', + label: 'Sub (v)', + items: [{ type: 'doc', id: 'c', label: 'C (v)' }], + }, + ], + }, + { type: 'link', href: 'https://example.com', label: 'External (v)' }, + ]); + }); +}); + +describe('collect* helpers', () => { + const sidebar: any[] = [ + category('Guides', [doc('guides/getting-started', 'Getting Started'), category('Sub', [doc('guides/sub/topic')])], { + type: 'doc', + id: 'guides/overview', + }), + doc('reference/api', 'API'), + link('External', 'https://x.y'), + ref('ref-to-a', 'Ref A'), + ]; + + test('collectSidebarDocItems returns all docs in flatten order', () => { + const docs = Utils.collectSidebarDocItems(sidebar); + expect(docs.map((d) => d.id)).toEqual([ + 'guides/getting-started', + 'guides/sub/topic', + 'reference/api', + ]); + }); + + test('collectSidebarCategories returns all categories', () => { + const cats = Utils.collectSidebarCategories(sidebar); + expect(cats.map((c) => c.label)).toEqual(['Guides', 'Sub']); + }); + + test('collectSidebarLinks returns all links', () => { + const links = Utils.collectSidebarLinks(sidebar); + expect(links.map((l) => l.href)).toEqual(['https://x.y']); + }); + + test('collectSidebarRefs returns all refs', () => { + const refs = Utils.collectSidebarRefs(sidebar) as any[]; + expect(refs.map((r) => r.id)).toEqual(['ref-to-a']); + }); + + test('collectSidebarDocIds includes category "doc" links and doc items only (order preserved)', () => { + const ids = Utils.collectSidebarDocIds(sidebar); + // Order: category(overview) then its children docs, then top-level docs + expect(ids).toEqual([ + 'guides/overview', + 'guides/getting-started', + 'guides/sub/topic', + 'reference/api', + ]); + }); + + test('collectSidebarNavigation includes docs and categories-with-link (excluding plain links/refs)', () => { + const nav = Utils.collectSidebarNavigation(sidebar); + // Expected sequence from flatten: category(Guides with link), docs, then doc 'reference/api' + expect(nav.map((i: any) => (i.type === 'doc' ? `doc:${i.id}` : `cat:${i.label}:${i.link.type}`))).toEqual([ + 'cat:Guides:doc', + 'doc:guides/getting-started', + 'cat:Sub:undefined', // Note: Sub has no link -> should not appear; verify it doesn't. + ].filter(Boolean as any)); + + // Ensure categories without link are not present + expect(nav.find((i: any) => i.type === 'category' && i.label === 'Sub')).toBeUndefined(); + }); +}); + +describe('collectSidebars* mappings', () => { + const sidebars: any = { + main: [ + category('Guides', [doc('a'), doc('b')], { type: 'doc', id: 'intro' }), + doc('c'), + ], + misc: [doc('x'), doc('y')], + }; + + test('collectSidebarsDocIds returns mapping of sidebarId -> doc ids', () => { + const map = Utils.collectSidebarsDocIds(sidebars); + expect(map).toEqual({ + main: ['intro', 'a', 'b', 'c'], + misc: ['x', 'y'], + }); + }); + + test('collectSidebarsNavigations returns mapping of sidebarId -> nav items', () => { + const map = Utils.collectSidebarsNavigations(sidebars); + expect(Array.isArray(map.main)).toBe(true); + expect(map.main.map((i: any) => (i.type === 'doc' ? `doc:${i.id}` : `cat:${i.label}`))).toEqual([ + 'cat:Guides', + 'doc:a', + 'doc:b', + 'doc:c', + ]); + expect(map.misc.map((i: any) => (i.type === 'doc' ? i.id : i.label))).toEqual(['x', 'y']); + }); +}); + +describe('createSidebarsUtils - navigation + queries', () => { + const sidebars: any = { + main: [ + category('Guides', [doc('guides/getting-started', 'Getting Started'), category('Advanced', [doc('guides/advanced/topic-a'), doc('guides/advanced/topic-b')], { type: 'generated-index', permalink: '/docs/guides/advanced' })], { + type: 'doc', + id: 'guides/overview', + }), + doc('reference/api', 'API'), + link('Ext', 'https://example.com'), + ], + misc: [doc('misc/a', 'Misc A'), doc('misc/b')], + nestedOnly: [ + category('Container', [ + category('Inner', [doc('inner/a')], { type: 'generated-index', permalink: '/docs/inner/index' }), + ]), + ], + empty: [], + }; + + const utils = Utils.createSidebarsUtils(sidebars); + + test('getFirstDocIdOfFirstSidebar returns first doc id of first sidebar (including category doc-link)', () => { + expect(utils.getFirstDocIdOfFirstSidebar()).toBe('guides/overview'); + }); + + test('getSidebarNameByDocId returns owning sidebar id', () => { + expect(utils.getSidebarNameByDocId('guides/overview')).toBe('main'); + expect(utils.getSidebarNameByDocId('misc/a')).toBe('misc'); + expect(utils.getSidebarNameByDocId('does-not-exist')).toBeUndefined(); + }); + + describe('getDocNavigation', () => { + test('returns correct prev/next around a middle doc (implicit sidebar)', () => { + const nav = utils.getDocNavigation({ + docId: 'guides/getting-started', + displayedSidebar: undefined, + unlistedIds: new Set(), + }); + + expect(nav.sidebarName).toBe('main'); + // Prev is the "Guides" category because it has a link + expect((nav.previous as any).type).toBe('category'); + expect((nav.previous as any).label).toBe('Guides'); + // Next is the "Advanced" category (generated-index) + expect((nav.next as any).type).toBe('category'); + expect((nav.next as any).label).toBe('Advanced'); + }); + + test('filters out unlisted doc ids, including category doc links', () => { + // Unlist the "Guides" category's doc link id + const nav = utils.getDocNavigation({ + docId: 'guides/getting-started', + displayedSidebar: undefined, + unlistedIds: new Set(['guides/overview']), + }); + expect(nav.previous).toBeUndefined(); + expect((nav.next as any).type).toBe('category'); + expect((nav.next as any).label).toBe('Advanced'); + }); + + test('explicit displayedSidebar used even if different from owning one', () => { + const nav = utils.getDocNavigation({ + docId: 'guides/getting-started', + displayedSidebar: 'main', + unlistedIds: new Set(), + }); + expect(nav.sidebarName).toBe('main'); + }); + + test('returns empty navigation when sidebarName is falsy (displayedSidebar = null)', () => { + const nav = utils.getDocNavigation({ + docId: 'guides/getting-started', + displayedSidebar: null, + unlistedIds: new Set(), + }); + expect(nav).toEqual({ + sidebarName: undefined, + previous: undefined, + next: undefined, + }); + }); + + test('throws when displayedSidebar does not exist', () => { + expect(() => + utils.getDocNavigation({ + docId: 'guides/getting-started', + displayedSidebar: 'unknown-sidebar', + unlistedIds: new Set(), + }) + ).toThrow(/wants to display sidebar unknown-sidebar but a sidebar with this name doesn't exist/); + }); + + test('when docId not part of navigation, returns sidebarName with undefined prev/next', () => { + const nav = utils.getDocNavigation({ + docId: 'not-in-nav', + displayedSidebar: 'main', + unlistedIds: new Set(), + }); + expect(nav).toEqual({ sidebarName: 'main', previous: undefined, next: undefined }); + }); + + test('boundary: last doc has undefined next', () => { + const nav = utils.getDocNavigation({ + docId: 'reference/api', + displayedSidebar: undefined, + unlistedIds: new Set(), + }); + expect(nav.sidebarName).toBe('main'); + expect(nav.next).toBeUndefined(); + expect((nav.previous as any).type).toBe('doc'); + expect((nav.previous as any).id).toBe('guides/advanced/topic-b'); + }); + }); + + describe('getCategoryGeneratedIndexList / getCategoryGeneratedIndexNavigation', () => { + test('returns list of categories with generated-index link', () => { + const list = utils.getCategoryGeneratedIndexList(); + const labels = list.map((i: any) => i.label).sort(); + expect(labels).toEqual(['Advanced', 'Inner']); + }); + + test('navigates around a category generated index by permalink', () => { + const nav = utils.getCategoryGeneratedIndexNavigation('/docs/guides/advanced'); + expect(nav.sidebarName).toBe('main'); + expect((nav.previous as any).type).toBe('doc'); + expect((nav.previous as any).id).toBe('guides/getting-started'); + expect((nav.next as any).type).toBe('doc'); + expect((nav.next as any).id).toBe('guides/advanced/topic-a'); + }); + + test('boundary: first/last generated index neighbors can be undefined', () => { + const nav = utils.getCategoryGeneratedIndexNavigation('/docs/inner/index'); + expect(nav.sidebarName).toBe('nestedOnly'); + expect(nav.previous).toBeUndefined(); + expect((nav.next as any).type).toBe('doc'); + expect((nav.next as any).id).toBe('inner/a'); + }); + }); + + describe('legacy validation helpers', () => { + test('checkLegacyVersionedSidebarNames throws with helpful error', () => { + const withLegacyNames: any = { + 'version-3.0.0-alpha/my': [doc('a')], + normal: [doc('b')], + }; + const u = Utils.createSidebarsUtils(withLegacyNames); + expect(() => + u.checkLegacyVersionedSidebarNames({ + versionMetadata: { versionName: '3.0.0-alpha' } as any, + sidebarFilePath: '/absolute/path/to/sidebars.js', + }) + ).toThrow(/legacy versioned sidebar names are not supported/i); + expect(() => + u.checkLegacyVersionedSidebarNames({ + versionMetadata: { versionName: '3.0.0-alpha' } as any, + sidebarFilePath: '/absolute/path/to/sidebars.js', + }) + ).toThrow(/Please remove the "version-3.0.0-alpha\/" prefix/); + }); + + test('checkSidebarsDocIds throws for invalid ids with list of available ids', () => { + // 'reference/api' exists in sidebars above, we intentionally omit it from allDocIds + const allDocIds = [ + 'guides/overview', + 'guides/getting-started', + 'guides/advanced/topic-a', + 'guides/advanced/topic-b', + ]; + expect(() => + utils.checkSidebarsDocIds({ + allDocIds, + sidebarFilePath: '/path/to/sidebars.js', + versionMetadata: { versionName: '3.0.0' } as any, + }) + ).toThrow(/These sidebar document ids do not exist:/i); + expect(() => + utils.checkSidebarsDocIds({ + allDocIds, + sidebarFilePath: '/path/to/sidebars.js', + versionMetadata: { versionName: '3.0.0' } as any, + }) + ).toThrow(/Available document ids are:/i); + }); + + test('checkSidebarsDocIds emits legacy doc id error first when prefixed ids present', () => { + const sb: any = { + s: [doc('version-1.2.3/intro')], + }; + const u = Utils.createSidebarsUtils(sb); + expect(() => + u.checkSidebarsDocIds({ + allDocIds: [], + sidebarFilePath: '/path/to/sidebars.js', + versionMetadata: { versionName: '1.2.3' } as any, + }) + ).toThrow(/legacy versioned document ids are not supported/i); + }); + }); + + describe('getFirstLink', () => { + test('returns doc link when first item is a category with doc link', () => { + const first = utils.getFirstLink('main')!; + expect(first).toEqual({ type: 'doc', id: 'guides/overview', label: 'Guides' }); + }); + + test('returns first top-level doc link with label fallback', () => { + const first = utils.getFirstLink('misc')!; + expect(first).toEqual({ type: 'doc', id: 'misc/a', label: 'Misc A' }); + }); + + test('returns generated-index from nested category when top-level category has no link', () => { + const first = utils.getFirstLink('nestedOnly')!; + expect(first).toEqual({ type: 'generated-index', permalink: '/docs/inner/index', label: 'Inner' }); + }); + + test('returns undefined for empty sidebar', () => { + expect(utils.getFirstLink('empty')).toBeUndefined(); + }); + }); +}); + +describe('toDocNavigationLink and toNavigationLink', () => { + const baseDoc = (overrides: any): any => ({ + title: 'Base Title', + permalink: '/base', + frontMatter: {}, + ...overrides, + }); + + test('toDocNavigationLink title precedence: pagination_label > sidebar_label > options.sidebarItemLabel > title', () => { + // 1) pagination_label wins + expect( + Utils.toDocNavigationLink( + baseDoc({ frontMatter: { pagination_label: 'P', sidebar_label: 'S' } }) + ) + ).toEqual({ title: 'P', permalink: '/base' }); + + // 2) sidebar_label when no pagination_label + expect( + Utils.toDocNavigationLink( + baseDoc({ frontMatter: { sidebar_label: 'S' } }) + ) + ).toEqual({ title: 'S', permalink: '/base' }); + + // 3) options.sidebarItemLabel when no fm labels + expect( + Utils.toDocNavigationLink( + baseDoc({}), + { sidebarItemLabel: 'FromItem' } + ) + ).toEqual({ title: 'FromItem', permalink: '/base' }); + + // 4) fallback to doc title + expect(Utils.toDocNavigationLink(baseDoc({}))).toEqual({ + title: 'Base Title', + permalink: '/base', + }); + }); + + describe('toNavigationLink', () => { + const docsById: Record = { + 'reference/api': baseDoc({ + title: 'API Title', + permalink: '/api', + frontMatter: {}, + }), + 'guides/overview': baseDoc({ + title: 'Overview Title', + permalink: '/overview', + frontMatter: { sidebar_label: 'Overview Sidebar' }, + }), + }; + + test('undefined navigation item -> undefined', () => { + expect(Utils.toNavigationLink(undefined, docsById)).toBeUndefined(); + }); + + test('category with generated-index link -> plain link with label + permalink', () => { + const nav = Utils.toNavigationLink( + { + type: 'category', + label: 'Advanced', + link: { type: 'generated-index', permalink: '/gen' }, + items: [], + } as any, + docsById + )!; + expect(nav).toEqual({ title: 'Advanced', permalink: '/gen' }); + }); + + test('category with doc link -> applies toDocNavigationLink on target doc', () => { + const nav = Utils.toNavigationLink( + { + type: 'category', + label: 'Guides', + link: { type: 'doc', id: 'guides/overview' }, + items: [], + } as any, + docsById + )!; + // sidebar_label should win from the doc metadata + expect(nav).toEqual({ title: 'Overview Sidebar', permalink: '/overview' }); + }); + + test('doc item -> toDocNavigationLink with sidebarItemLabel as fallback', () => { + const nav = Utils.toNavigationLink( + { type: 'doc', id: 'reference/api', label: 'API Label' } as any, + docsById + )!; + // No fm labels => title should be "API Label" (from sidebar item label) + expect(nav).toEqual({ title: 'API Label', permalink: '/api' }); + }); + + test('throws if doc id missing in docsById', () => { + expect(() => + Utils.toNavigationLink({ type: 'doc', id: 'missing' } as any, docsById) + ).toThrow(/no doc found with id=missing/); + }); + }); +}); \ No newline at end of file