diff --git a/website/.gitignore b/website/.gitignore index b7e1fe32d13..002906039d3 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -11,6 +11,8 @@ .docusaurus .cache-loader static/release-notes +docs.json +tutorials.json # Cache .eslintcache diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 5302c5ee580..79f67e7e11e 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -3,6 +3,7 @@ import { resolve } from 'node:path'; import { createNotesFiles } from './release-notes-parser'; import Storybook from './storybook'; +import { generateJsonOverviewFile } from './sidebar-content-parser'; const lightCodeTheme = require('prism-react-renderer').themes.github; const darkCodeTheme = require('prism-react-renderer').themes.dracula; @@ -339,6 +340,22 @@ const config = { id: 'tutorial', path: 'tutorial', routeBasePath: 'tutorial', + + // Extract tutorial navigation using the navigation utils + /** @param {{ defaultSidebarItemsGenerator: any, [key: string]: any }} param0 */ + async sidebarItemsGenerator({ defaultSidebarItemsGenerator, ...args }) { + const sidebarItems = await defaultSidebarItemsGenerator(args); + + // Generate tutorials navigation using the utility function + await generateJsonOverviewFile( + sidebarItems, + 'tutorial', + 'https://podman-desktop.io', + './static/tutorials.json', + ); + + return sidebarItems; + }, }, ], './src/plugins/github-metadata-plugin.ts', @@ -375,6 +392,19 @@ const config = { sidebarCollapsed: false, sidebarPath: require.resolve('./sidebars.js'), editUrl: 'https://github.com/podman-desktop/podman-desktop/tree/main/website', + + // Enhanced function to extract navigation using the navigation utils + /** @param {{ defaultSidebarItemsGenerator: any, [key: string]: any }} param0 */ + async sidebarItemsGenerator({ defaultSidebarItemsGenerator, ...args }) { + // Get the default sidebar items (what Docusaurus normally generates) + const sidebarItems = await defaultSidebarItemsGenerator(args); + + // Generate docs navigation using the utility function + await generateJsonOverviewFile(sidebarItems, 'docs', 'https://podman-desktop.io', './static/docs.json'); + + // Return the original sidebar items (unchanged) + return sidebarItems; + }, }, blog: { blogTitle: 'Podman Desktop blog!', diff --git a/website/sidebar-content-parser.spec.ts b/website/sidebar-content-parser.spec.ts new file mode 100644 index 00000000000..5ecd203bd29 --- /dev/null +++ b/website/sidebar-content-parser.spec.ts @@ -0,0 +1,304 @@ +import { writeFile } from 'node:fs/promises'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { extractNavigationFromSidebar, generateJsonOverviewFile } from './sidebar-content-parser'; + +// Mock the writeFile function +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('extractNavigationFromSidebar', () => { + test('should extract navigation items from simple doc items', () => { + const sidebarItems = [ + { + type: 'doc' as const, + id: 'intro', + label: 'Introduction', + }, + { + type: 'doc' as const, + id: 'running-a-kubernetes-cluster', + label: 'Running a Kubernetes cluster', + }, + ]; + + const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); + + expect(result).toEqual([ + { + name: 'Introduction', + url: 'https://podman-desktop.io/docs/intro', + }, + { + name: 'Running a kubernetes cluster', + url: 'https://podman-desktop.io/docs/running-a-kubernetes-cluster', + }, + ]); + }); + + test('should handle items without labels by using id and formatting it', () => { + const sidebarItems = [ + { + type: 'doc' as const, + id: 'deploying-a-kubernetes-application', + }, + ]; + + const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); + + expect(result).toEqual([ + { + name: 'Deploying a kubernetes application', + url: 'https://podman-desktop.io/docs/deploying-a-kubernetes-application', + }, + ]); + }); + + test('should handle tutorial index case correctly', () => { + const sidebarItems = [ + { + type: 'doc' as const, + id: 'index', + label: 'Introduction', + }, + ]; + + const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'tutorial'); + + expect(result).toEqual([ + { + name: 'Introduction', + url: 'https://podman-desktop.io/tutorial', + }, + ]); + }); + + test('should handle category items recursively', () => { + const sidebarItems = [ + { + type: 'category' as const, + label: 'Getting Started', + items: [ + { + type: 'doc' as const, + id: 'intro', + label: 'Introduction', + }, + { + type: 'doc' as const, + id: 'installation', + label: 'Installation', + }, + ], + }, + ]; + + const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); + + expect(result).toEqual([ + { + name: 'Introduction', + url: 'https://podman-desktop.io/docs/intro', + }, + { + name: 'Installation', + url: 'https://podman-desktop.io/docs/installation', + }, + ]); + }); + + test('should format names correctly by replacing hyphens with spaces and capitalizing first letter', () => { + const sidebarItems = [ + { + type: 'doc' as const, + id: 'getting-started-with-compose', + label: 'Getting started with Compose', + }, + { + type: 'doc' as const, + id: 'running-an-ai-application', + }, + ]; + + const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); + + expect(result).toEqual([ + { + name: 'Getting started with compose', + url: 'https://podman-desktop.io/docs/getting-started-with-compose', + }, + { + name: 'Running an AI application', + url: 'https://podman-desktop.io/docs/running-an-ai-application', + }, + ]); + }); + + test('should keep AI capitalized in names', () => { + const sidebarItems = [ + { + type: 'doc' as const, + id: 'ai-lab-start-recipe', + label: 'AI Lab Start Recipe', + }, + { + type: 'doc' as const, + id: 'running-an-ai-application', + }, + { + type: 'doc' as const, + id: 'ai-powered-tools', + }, + ]; + + const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); + + expect(result).toEqual([ + { + name: 'AI lab start recipe', + url: 'https://podman-desktop.io/docs/ai-lab-start-recipe', + }, + { + name: 'Running an AI application', + url: 'https://podman-desktop.io/docs/running-an-ai-application', + }, + { + name: 'AI powered tools', + url: 'https://podman-desktop.io/docs/ai-powered-tools', + }, + ]); + }); + + test('should skip items with empty names', () => { + const sidebarItems = [ + { + type: 'doc' as const, + id: '', + label: '', + }, + { + type: 'doc' as const, + id: 'valid-item', + label: 'Valid Item', + }, + ]; + + const result = extractNavigationFromSidebar(sidebarItems, 'https://podman-desktop.io', 'docs'); + + expect(result).toEqual([ + { + name: 'Valid item', + url: 'https://podman-desktop.io/docs/valid-item', + }, + ]); + }); +}); + +describe('generateJsonOverviewFile', () => { + test('should generate JSON file for docs section', async () => { + const sidebarItems = [ + { + type: 'doc' as const, + id: 'intro', + label: 'Introduction', + }, + ]; + + await generateJsonOverviewFile(sidebarItems, 'docs', 'https://podman-desktop.io', './static/docs.json'); + + expect(writeFile).toHaveBeenCalledWith( + './static/docs.json', + JSON.stringify([ + { + name: 'Introduction', + url: 'https://podman-desktop.io/docs/intro', + }, + ]), + ); + }); + + test('should generate JSON file for tutorial section', async () => { + const sidebarItems = [ + { + type: 'doc' as const, + id: 'index', + label: 'Introduction', + }, + ]; + + await generateJsonOverviewFile(sidebarItems, 'tutorial', 'https://podman-desktop.io', './static/tutorials.json'); + + expect(writeFile).toHaveBeenCalledWith( + './static/tutorials.json', + JSON.stringify([ + { + name: 'Introduction', + url: 'https://podman-desktop.io/tutorial', + }, + ]), + ); + }); + + test('should sort items alphabetically by name', async () => { + const sidebarItems = [ + { + type: 'doc' as const, + id: 'zebra', + label: 'Zebra', + }, + { + type: 'doc' as const, + id: 'apple', + label: 'Apple', + }, + { + type: 'doc' as const, + id: 'banana', + label: 'Banana', + }, + ]; + + await generateJsonOverviewFile(sidebarItems, 'docs', 'https://podman-desktop.io', './static/docs.json'); + + const expectedContent = JSON.stringify([ + { + name: 'Apple', + url: 'https://podman-desktop.io/docs/apple', + }, + { + name: 'Banana', + url: 'https://podman-desktop.io/docs/banana', + }, + { + name: 'Zebra', + url: 'https://podman-desktop.io/docs/zebra', + }, + ]); + + expect(writeFile).toHaveBeenCalledWith('./static/docs.json', expectedContent); + }); + + test('should propagate writeFile errors', async () => { + const mockError = new Error('Write failed'); + vi.mocked(writeFile).mockRejectedValueOnce(mockError); + + const sidebarItems = [ + { + type: 'doc' as const, + id: 'intro', + label: 'Introduction', + }, + ]; + + // Should throw the error + await expect( + generateJsonOverviewFile(sidebarItems, 'docs', 'https://podman-desktop.io', './static/docs.json'), + ).rejects.toThrow('Write failed'); + }); +}); diff --git a/website/sidebar-content-parser.ts b/website/sidebar-content-parser.ts new file mode 100644 index 00000000000..4db9913bd9c --- /dev/null +++ b/website/sidebar-content-parser.ts @@ -0,0 +1,96 @@ +import { writeFile } from 'node:fs/promises'; + +// Type definitions for Docusaurus sidebar items +interface DocSidebarItem { + type: 'doc'; + id: string; + label?: string; +} + +interface CategorySidebarItem { + type: 'category'; + label?: string; + items: SidebarItem[]; +} + +type SidebarItem = DocSidebarItem | CategorySidebarItem; + +interface NavigationItem { + name: string; + url: string; +} + +/** + * Extract navigation items from sidebar structure + * @param items - Sidebar items from Docusaurus + * @param baseUrl - Base URL for the site + * @param section - Section type ('docs' or 'tutorial') + * @returns Navigation items + */ +export function extractNavigationFromSidebar( + items: SidebarItem[], + baseUrl: string, + section: 'docs' | 'tutorial' = 'docs', +): NavigationItem[] { + const navItems: NavigationItem[] = []; + + items.forEach(item => { + if (item.type === 'doc') { + // Use the label (display name) instead of the id for better readability + const rawName = item.label ?? item.id; + if (!rawName) { + return; + } + + // Replace hashes with spaces and capitalize only the first letter of the whole name + const processedName = rawName + .replace(/-/g, ' ') // Replace hyphens with spaces + .replace(/\s+/g, ' ') // Normalize multiple spaces to single space + .trim() // Remove leading/trailing spaces + .toLowerCase() // Convert to lowercase + .replace(/\bai\b/g, 'AI'); // Keep "AI" capitalized (word boundary to avoid partial matches) + + let name = processedName.charAt(0).toUpperCase() + processedName.slice(1); + + // Handle tutorial index case - it should point to base /tutorial URL + let url: string; + if (section === 'tutorial' && item.id === 'index') { + url = `${baseUrl}/tutorial`; + name = 'Introduction'; + } else { + url = `${baseUrl}/${section}/${item.id}`; + } + + navItems.push({ + name: name, + url: url, + }); + } else if (item.type === 'category' && item.items) { + // Recursively extract from categories + navItems.push(...extractNavigationFromSidebar(item.items, baseUrl, section)); + } + }); + + return navItems; +} + +/** + * Generate JSON overview file for navigation + * @param sidebarItems - Sidebar items from Docusaurus + * @param section - Section type ('docs' or 'tutorial') + * @param baseUrl - Base URL for the site + * @param outputPath - Path to output the JSON file + */ +export async function generateJsonOverviewFile( + sidebarItems: SidebarItem[], + section: 'docs' | 'tutorial', + baseUrl: string, + outputPath: string, +): Promise { + const navItems = extractNavigationFromSidebar(sidebarItems, baseUrl, section); + + await writeFile( + outputPath, + JSON.stringify(navItems.toSorted((a: NavigationItem, b: NavigationItem) => a.name.localeCompare(b.name))), + ); +}