From e022a2efb4239a10a06b9c177e65c3fa2b3b5ba8 Mon Sep 17 00:00:00 2001 From: Caner Akdas Date: Thu, 2 Apr 2026 19:43:19 +0300 Subject: [PATCH 01/13] feat: Sidebar enhancements --- src/generators/web/constants.mjs | 122 ++++++++++++++++++ .../web/ui/components/SideBar/index.jsx | 23 ++-- src/generators/web/ui/index.css | 5 + src/generators/web/ui/types.d.ts | 2 +- .../web/ui/utils/__tests__/sidebar.test.mjs | 66 ++++++++++ src/generators/web/ui/utils/sidebar.mjs | 62 +++++++++ .../web/utils/__tests__/config.test.mjs | 9 +- src/generators/web/utils/config.mjs | 11 +- 8 files changed, 280 insertions(+), 20 deletions(-) create mode 100644 src/generators/web/ui/utils/__tests__/sidebar.test.mjs create mode 100644 src/generators/web/ui/utils/sidebar.mjs diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index 5cbfe760..ab3049b7 100644 --- a/src/generators/web/constants.mjs +++ b/src/generators/web/constants.mjs @@ -81,3 +81,125 @@ export const SPECULATION_RULES = JSON.stringify({ { where: { selector_matches: '[rel~=prefetch]' }, eagerness: 'moderate' }, ], }); + +/** + * @deprecated This is being exported temporarily during the transition period. + * For a more general solution, category information should be added to pages in + * YAML format, and this array should be removed. + * + * Defines the sidebar navigation groups and their associated page URLs. + * @type {Array<{ groupName: string, items: Array }>} + */ +export const SIDEBAR_GROUPS = [ + { + groupName: 'Getting Started', + items: [ + 'documentation.html', + 'synopsis.html', + 'cli.html', + 'environment_variables.html', + 'globals.html', + ], + }, + { + groupName: 'Module System', + items: [ + 'modules.html', + 'esm.html', + 'module.html', + 'packages.html', + 'typescript.html', + ], + }, + { + groupName: 'Networking & Protocols', + items: [ + 'http.html', + 'http2.html', + 'https.html', + 'net.html', + 'dns.html', + 'dgram.html', + 'quic.html', + ], + }, + { + groupName: 'File System & I/O', + items: [ + 'fs.html', + 'path.html', + 'buffer.html', + 'stream.html', + 'string_decoder.html', + 'zlib.html', + 'readline.html', + 'tty.html', + ], + }, + { + groupName: 'Asynchronous Programming', + items: [ + 'async_context.html', + 'async_hooks.html', + 'events.html', + 'timers.html', + 'webstreams.html', + ], + }, + { + groupName: 'Process & Concurrency', + items: [ + 'process.html', + 'child_process.html', + 'cluster.html', + 'worker_threads.html', + 'os.html', + ], + }, + { + groupName: 'Security & Cryptography', + items: ['crypto.html', 'webcrypto.html', 'permissions.html', 'tls.html'], + }, + { + groupName: 'Data & URL Utilities', + items: ['url.html', 'querystring.html', 'punycode.html', 'util.html'], + }, + { + groupName: 'Debugging & Diagnostics', + items: [ + 'debugger.html', + 'inspector.html', + 'console.html', + 'report.html', + 'tracing.html', + 'diagnostics_channel.html', + 'errors.html', + ], + }, + { + groupName: 'Testing & Assertion', + items: ['test.html', 'assert.html', 'repl.html'], + }, + { + groupName: 'Performance & Observability', + items: ['perf_hooks.html', 'v8.html'], + }, + { + groupName: 'Runtime & Advanced APIs', + items: [ + 'vm.html', + 'wasi.html', + 'sqlite.html', + 'single-executable-applications.html', + 'intl.html', + ], + }, + { + groupName: 'Native & Low-level Extensions', + items: ['addons.html', 'n-api.html', 'embedding.html'], + }, + { + groupName: 'Legacy & Deprecated', + items: ['deprecations.html', 'domain.html'], + }, +]; diff --git a/src/generators/web/ui/components/SideBar/index.jsx b/src/generators/web/ui/components/SideBar/index.jsx index d2965d17..aede4b7a 100644 --- a/src/generators/web/ui/components/SideBar/index.jsx +++ b/src/generators/web/ui/components/SideBar/index.jsx @@ -3,6 +3,7 @@ import SideBar from '@node-core/ui-components/Containers/Sidebar'; import styles from './index.module.css'; import { relative } from '../../../../../utils/url.mjs'; +import { buildSideBarGroups } from '../../utils/sidebar.mjs'; import { title, version, versions, pages } from '#theme/config'; @@ -36,32 +37,30 @@ export default ({ metadata }) => { label, })); - const items = pages.map(([heading, path]) => ({ + const items = pages.map(([heading, path, category]) => ({ label: heading, link: metadata.path === path ? `${metadata.basename}.html` : `${relative(path, metadata.path)}.html`, + category, })); return ( } title="Navigation" > -
- ); }; diff --git a/src/generators/web/ui/index.css b/src/generators/web/ui/index.css index 3b0deb3e..b381f0b2 100644 --- a/src/generators/web/ui/index.css +++ b/src/generators/web/ui/index.css @@ -118,3 +118,8 @@ main { } } } + +/* Override the min-width of the select component used for version selection in the sidebar */ +[class*='select'] button[role='combobox'] { + min-width: initial; +} diff --git a/src/generators/web/ui/types.d.ts b/src/generators/web/ui/types.d.ts index 47a7cbe5..f26e911c 100644 --- a/src/generators/web/ui/types.d.ts +++ b/src/generators/web/ui/types.d.ts @@ -15,7 +15,7 @@ declare module '#theme/config' { major: number; }>; export const editURL: string; - export const pages: Array<[string, string]>; + export const pages: Array<[string, string, string?]>; export const languageDisplayNameMap: Map; } diff --git a/src/generators/web/ui/utils/__tests__/sidebar.test.mjs b/src/generators/web/ui/utils/__tests__/sidebar.test.mjs new file mode 100644 index 00000000..a82419ea --- /dev/null +++ b/src/generators/web/ui/utils/__tests__/sidebar.test.mjs @@ -0,0 +1,66 @@ +'use strict'; + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildSideBarGroups } from '../sidebar.mjs'; + +describe('buildSideBarGroups', () => { + it('groups entries by category and preserves insertion order', () => { + const frontmatter = [ + { label: 'FS', link: '/api/fs.html', category: 'File System' }, + { label: 'HTTP', link: '/api/http.html', category: 'Networking' }, + { label: 'Path', link: '/api/path.html', category: 'File System' }, + ]; + + const result = buildSideBarGroups(frontmatter); + + assert.deepStrictEqual(result, [ + { + groupName: 'File System', + items: [ + { label: 'FS', link: '/api/fs.html' }, + { label: 'Path', link: '/api/path.html' }, + ], + }, + { + groupName: 'Networking', + items: [{ label: 'HTTP', link: '/api/http.html' }], + }, + ]); + }); + + it('puts entries without category into an Others group at the end by default', () => { + const frontmatter = [ + { label: 'Buffer', link: '/api/buffer.html', category: 'Binary' }, + { label: 'Unknown', link: '/api/unknown.html' }, + { label: 'Config', link: '/api/config.html', category: '' }, + ]; + + const result = buildSideBarGroups(frontmatter); + + assert.equal(result.at(-1).groupName, 'Others'); + assert.deepStrictEqual(result.at(-1).items, [ + { label: 'Unknown', link: '/api/unknown.html' }, + { label: 'Config', link: '/api/config.html' }, + ]); + }); + + it('uses a custom default group name when provided', () => { + const result = buildSideBarGroups( + [{ label: 'Unknown', link: '/api/unknown.html' }], + 'General' + ); + + assert.deepStrictEqual(result, [ + { + groupName: 'General', + items: [{ label: 'Unknown', link: '/api/unknown.html' }], + }, + ]); + }); + + it('returns an empty array when given no entries', () => { + assert.deepStrictEqual(buildSideBarGroups([]), []); + }); +}); diff --git a/src/generators/web/ui/utils/sidebar.mjs b/src/generators/web/ui/utils/sidebar.mjs new file mode 100644 index 00000000..987be3d5 --- /dev/null +++ b/src/generators/web/ui/utils/sidebar.mjs @@ -0,0 +1,62 @@ +import { SIDEBAR_GROUPS } from '../../constants.mjs'; + +/** + * @deprecated This is being exported temporarily during the transition period. + * Reverse lookup: filename (e.g. 'fs.html') → groupName, used as category + * fallback for pages without explicit category in metadata. + */ +export const fileToGroup = new Map( + SIDEBAR_GROUPS.flatMap(({ groupName, items }) => + items.map(item => [item, groupName]) + ) +); + +/** + * Builds grouped sidebar navigation from categorized page entries. + * Pages without a category are placed under the provided default group. + * + * @param {Array<{ label: string, link: string, category?: string }>} frontmatter + * @param {string} [defaultGroupName='Others'] + * @returns {Array<{ groupName: string, items: Array<{ label: string, link: string }> }>} + */ +export const buildSideBarGroups = ( + frontmatter, + defaultGroupName = 'Others' +) => { + const groups = new Map(); + const others = []; + + // Group entries by category while preserving insertion order + for (const { label, link, category } of frontmatter) { + const linkFilename = link.split('/').at(-1); + + // Skip index pages as they are typically the main entry point for a section + // and may not need to be listed separately in the sidebar. + if (linkFilename === 'index.html') { + continue; + } + + const resolvedCategory = category ?? fileToGroup.get(linkFilename); + + if (!resolvedCategory) { + others.push({ label, link }); + continue; + } + + const items = groups.get(resolvedCategory) ?? []; + items.push({ label, link }); + groups.set(resolvedCategory, items); + } + + // Convert the groups map to an array while preserving the original order of categories + const orderedGroups = [...groups.entries()].map(([groupName, items]) => ({ + groupName, + items, + })); + + if (others.length > 0) { + orderedGroups.push({ groupName: defaultGroupName, items: others }); + } + + return orderedGroups; +}; diff --git a/src/generators/web/utils/__tests__/config.test.mjs b/src/generators/web/utils/__tests__/config.test.mjs index 807183ba..967a41f9 100644 --- a/src/generators/web/utils/__tests__/config.test.mjs +++ b/src/generators/web/utils/__tests__/config.test.mjs @@ -43,6 +43,7 @@ const makeEntry = (api, name, path) => ({ data: { api, path, + category: api === 'fs' ? 'File System' : undefined, heading: { depth: 1, data: { name } }, }, }); @@ -101,7 +102,7 @@ describe('buildVersionEntries', () => { }); describe('buildPageList', () => { - it('returns sorted [name, path] tuples from input entries', () => { + it('returns sorted [name, path, category] tuples from input entries', () => { const input = [ makeEntry('http', 'HTTP', '/http'), makeEntry('fs', 'File System', '/fs'), @@ -111,8 +112,8 @@ describe('buildPageList', () => { assert.equal(result.length, 2); // Sorted alphabetically by name - assert.deepStrictEqual(result[0], ['File System', '/fs']); - assert.deepStrictEqual(result[1], ['HTTP', '/http']); + assert.deepStrictEqual(result[0], ['File System', '/fs', 'File System']); + assert.deepStrictEqual(result[1], ['HTTP', '/http', undefined]); }); it('filters out entries whose heading depth is not 1', () => { @@ -130,7 +131,7 @@ describe('buildPageList', () => { const result = buildPageList(input); assert.equal(result.length, 1); - assert.deepStrictEqual(result[0], ['File System', '/fs']); + assert.deepStrictEqual(result[0], ['File System', '/fs', 'File System']); }); }); diff --git a/src/generators/web/utils/config.mjs b/src/generators/web/utils/config.mjs index d1a175f7..81215feb 100644 --- a/src/generators/web/utils/config.mjs +++ b/src/generators/web/utils/config.mjs @@ -33,11 +33,16 @@ export function buildVersionEntries(config, pageURLBase) { * Pre-compute sorted page list for sidebar navigation. * * @param {Array} input - * @returns {Array<[string, string]>} + * @returns {Array<[string, string, string?]>} */ export function buildPageList(input) { - const headNodes = getSortedHeadNodes(input.map(e => e.data)); - return headNodes.map(node => [node.heading.data.name, node.path]); + const headNodes = getSortedHeadNodes(input.map(({ data }) => data)); + + return headNodes.map(({ path, category, heading }) => [ + heading.data.name, + path, + category, + ]); } /** From 9b03c22f439ea75642c20b90a699af96b50752c5 Mon Sep 17 00:00:00 2001 From: Caner Akdas Date: Thu, 2 Apr 2026 19:55:33 +0300 Subject: [PATCH 02/13] docs: enhance param name --- src/generators/web/ui/utils/sidebar.mjs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/generators/web/ui/utils/sidebar.mjs b/src/generators/web/ui/utils/sidebar.mjs index 987be3d5..d8379147 100644 --- a/src/generators/web/ui/utils/sidebar.mjs +++ b/src/generators/web/ui/utils/sidebar.mjs @@ -2,7 +2,7 @@ import { SIDEBAR_GROUPS } from '../../constants.mjs'; /** * @deprecated This is being exported temporarily during the transition period. - * Reverse lookup: filename (e.g. 'fs.html') → groupName, used as category + * Reverse lookup: filename (e.g. 'fs.html') -> groupName, used as category * fallback for pages without explicit category in metadata. */ export const fileToGroup = new Map( @@ -15,19 +15,16 @@ export const fileToGroup = new Map( * Builds grouped sidebar navigation from categorized page entries. * Pages without a category are placed under the provided default group. * - * @param {Array<{ label: string, link: string, category?: string }>} frontmatter + * @param {Array<{ label: string, link: string, category?: string }>} items * @param {string} [defaultGroupName='Others'] * @returns {Array<{ groupName: string, items: Array<{ label: string, link: string }> }>} */ -export const buildSideBarGroups = ( - frontmatter, - defaultGroupName = 'Others' -) => { +export const buildSideBarGroups = (items, defaultGroupName = 'Others') => { const groups = new Map(); const others = []; // Group entries by category while preserving insertion order - for (const { label, link, category } of frontmatter) { + for (const { label, link, category } of items) { const linkFilename = link.split('/').at(-1); // Skip index pages as they are typically the main entry point for a section From 105243bce1a223e2efc237a774b1c9c8a33c0a15 Mon Sep 17 00:00:00 2001 From: Caner Akdas Date: Thu, 2 Apr 2026 20:11:02 +0300 Subject: [PATCH 03/13] chore: seperate ui constants --- src/generators/web/constants.mjs | 122 ------------------------ src/generators/web/ui/constants.mjs | 121 +++++++++++++++++++++++ src/generators/web/ui/utils/sidebar.mjs | 2 +- 3 files changed, 122 insertions(+), 123 deletions(-) create mode 100644 src/generators/web/ui/constants.mjs diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index ab3049b7..5cbfe760 100644 --- a/src/generators/web/constants.mjs +++ b/src/generators/web/constants.mjs @@ -81,125 +81,3 @@ export const SPECULATION_RULES = JSON.stringify({ { where: { selector_matches: '[rel~=prefetch]' }, eagerness: 'moderate' }, ], }); - -/** - * @deprecated This is being exported temporarily during the transition period. - * For a more general solution, category information should be added to pages in - * YAML format, and this array should be removed. - * - * Defines the sidebar navigation groups and their associated page URLs. - * @type {Array<{ groupName: string, items: Array }>} - */ -export const SIDEBAR_GROUPS = [ - { - groupName: 'Getting Started', - items: [ - 'documentation.html', - 'synopsis.html', - 'cli.html', - 'environment_variables.html', - 'globals.html', - ], - }, - { - groupName: 'Module System', - items: [ - 'modules.html', - 'esm.html', - 'module.html', - 'packages.html', - 'typescript.html', - ], - }, - { - groupName: 'Networking & Protocols', - items: [ - 'http.html', - 'http2.html', - 'https.html', - 'net.html', - 'dns.html', - 'dgram.html', - 'quic.html', - ], - }, - { - groupName: 'File System & I/O', - items: [ - 'fs.html', - 'path.html', - 'buffer.html', - 'stream.html', - 'string_decoder.html', - 'zlib.html', - 'readline.html', - 'tty.html', - ], - }, - { - groupName: 'Asynchronous Programming', - items: [ - 'async_context.html', - 'async_hooks.html', - 'events.html', - 'timers.html', - 'webstreams.html', - ], - }, - { - groupName: 'Process & Concurrency', - items: [ - 'process.html', - 'child_process.html', - 'cluster.html', - 'worker_threads.html', - 'os.html', - ], - }, - { - groupName: 'Security & Cryptography', - items: ['crypto.html', 'webcrypto.html', 'permissions.html', 'tls.html'], - }, - { - groupName: 'Data & URL Utilities', - items: ['url.html', 'querystring.html', 'punycode.html', 'util.html'], - }, - { - groupName: 'Debugging & Diagnostics', - items: [ - 'debugger.html', - 'inspector.html', - 'console.html', - 'report.html', - 'tracing.html', - 'diagnostics_channel.html', - 'errors.html', - ], - }, - { - groupName: 'Testing & Assertion', - items: ['test.html', 'assert.html', 'repl.html'], - }, - { - groupName: 'Performance & Observability', - items: ['perf_hooks.html', 'v8.html'], - }, - { - groupName: 'Runtime & Advanced APIs', - items: [ - 'vm.html', - 'wasi.html', - 'sqlite.html', - 'single-executable-applications.html', - 'intl.html', - ], - }, - { - groupName: 'Native & Low-level Extensions', - items: ['addons.html', 'n-api.html', 'embedding.html'], - }, - { - groupName: 'Legacy & Deprecated', - items: ['deprecations.html', 'domain.html'], - }, -]; diff --git a/src/generators/web/ui/constants.mjs b/src/generators/web/ui/constants.mjs new file mode 100644 index 00000000..9b117b7f --- /dev/null +++ b/src/generators/web/ui/constants.mjs @@ -0,0 +1,121 @@ +/** + * @deprecated This is being exported temporarily during the transition period. + * For a more general solution, category information should be added to pages in + * YAML format, and this array should be removed. + * + * Defines the sidebar navigation groups and their associated page URLs. + * @type {Array<{ groupName: string, items: Array }>} + */ +export const SIDEBAR_GROUPS = [ + { + groupName: 'Getting Started', + items: [ + 'documentation.html', + 'synopsis.html', + 'cli.html', + 'environment_variables.html', + 'globals.html', + ], + }, + { + groupName: 'Module System', + items: [ + 'modules.html', + 'esm.html', + 'module.html', + 'packages.html', + 'typescript.html', + ], + }, + { + groupName: 'Networking & Protocols', + items: [ + 'http.html', + 'http2.html', + 'https.html', + 'net.html', + 'dns.html', + 'dgram.html', + 'quic.html', + ], + }, + { + groupName: 'File System & I/O', + items: [ + 'fs.html', + 'path.html', + 'buffer.html', + 'stream.html', + 'string_decoder.html', + 'zlib.html', + 'readline.html', + 'tty.html', + ], + }, + { + groupName: 'Asynchronous Programming', + items: [ + 'async_context.html', + 'async_hooks.html', + 'events.html', + 'timers.html', + 'webstreams.html', + ], + }, + { + groupName: 'Process & Concurrency', + items: [ + 'process.html', + 'child_process.html', + 'cluster.html', + 'worker_threads.html', + 'os.html', + ], + }, + { + groupName: 'Security & Cryptography', + items: ['crypto.html', 'webcrypto.html', 'permissions.html', 'tls.html'], + }, + { + groupName: 'Data & URL Utilities', + items: ['url.html', 'querystring.html', 'punycode.html', 'util.html'], + }, + { + groupName: 'Debugging & Diagnostics', + items: [ + 'debugger.html', + 'inspector.html', + 'console.html', + 'report.html', + 'tracing.html', + 'diagnostics_channel.html', + 'errors.html', + ], + }, + { + groupName: 'Testing & Assertion', + items: ['test.html', 'assert.html', 'repl.html'], + }, + { + groupName: 'Performance & Observability', + items: ['perf_hooks.html', 'v8.html'], + }, + { + groupName: 'Runtime & Advanced APIs', + items: [ + 'vm.html', + 'wasi.html', + 'sqlite.html', + 'single-executable-applications.html', + 'intl.html', + ], + }, + { + groupName: 'Native & Low-level Extensions', + items: ['addons.html', 'n-api.html', 'embedding.html'], + }, + { + groupName: 'Legacy & Deprecated', + items: ['deprecations.html', 'domain.html'], + }, +]; diff --git a/src/generators/web/ui/utils/sidebar.mjs b/src/generators/web/ui/utils/sidebar.mjs index d8379147..22d3d7d8 100644 --- a/src/generators/web/ui/utils/sidebar.mjs +++ b/src/generators/web/ui/utils/sidebar.mjs @@ -1,4 +1,4 @@ -import { SIDEBAR_GROUPS } from '../../constants.mjs'; +import { SIDEBAR_GROUPS } from '../constants.mjs'; /** * @deprecated This is being exported temporarily during the transition period. From 53832835f210b140b680289d444680d2a613bebf Mon Sep 17 00:00:00 2001 From: Caner Akdas Date: Thu, 2 Apr 2026 20:14:00 +0300 Subject: [PATCH 04/13] Update src/generators/web/ui/utils/sidebar.mjs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/generators/web/ui/utils/sidebar.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/generators/web/ui/utils/sidebar.mjs b/src/generators/web/ui/utils/sidebar.mjs index 22d3d7d8..81ec5bb3 100644 --- a/src/generators/web/ui/utils/sidebar.mjs +++ b/src/generators/web/ui/utils/sidebar.mjs @@ -40,9 +40,9 @@ export const buildSideBarGroups = (items, defaultGroupName = 'Others') => { continue; } - const items = groups.get(resolvedCategory) ?? []; - items.push({ label, link }); - groups.set(resolvedCategory, items); + const groupItems = groups.get(resolvedCategory) ?? []; + groupItems.push({ label, link }); + groups.set(resolvedCategory, groupItems); } // Convert the groups map to an array while preserving the original order of categories From 6dd889cf3ac974d2e9ce1f06490ad12a7dcf429f Mon Sep 17 00:00:00 2001 From: Caner Akdas Date: Thu, 2 Apr 2026 20:24:46 +0300 Subject: [PATCH 05/13] chore: new modules added --- src/generators/web/ui/constants.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/generators/web/ui/constants.mjs b/src/generators/web/ui/constants.mjs index 9b117b7f..3bd943d9 100644 --- a/src/generators/web/ui/constants.mjs +++ b/src/generators/web/ui/constants.mjs @@ -50,6 +50,7 @@ export const SIDEBAR_GROUPS = [ 'zlib.html', 'readline.html', 'tty.html', + 'zlib_iter.html', ], }, { @@ -60,6 +61,7 @@ export const SIDEBAR_GROUPS = [ 'events.html', 'timers.html', 'webstreams.html', + 'stream_iter.html', ], }, { From b9b0ca6a1138f2ad9e6459b989b43405bfd64c2e Mon Sep 17 00:00:00 2001 From: Caner Akdas Date: Fri, 3 Apr 2026 23:11:14 +0300 Subject: [PATCH 06/13] chore: extracting utilities and adding more unit tests --- .../web/ui/components/SideBar/index.jsx | 44 +--- .../SideBar/utils/__tests__/index.test.mjs | 217 ++++++++++++++++++ .../web/ui/components/SideBar/utils/index.mjs | 116 ++++++++++ .../web/ui/utils/__tests__/sidebar.test.mjs | 66 ------ src/generators/web/ui/utils/sidebar.mjs | 59 ----- 5 files changed, 342 insertions(+), 160 deletions(-) create mode 100644 src/generators/web/ui/components/SideBar/utils/__tests__/index.test.mjs create mode 100644 src/generators/web/ui/components/SideBar/utils/index.mjs delete mode 100644 src/generators/web/ui/utils/__tests__/sidebar.test.mjs delete mode 100644 src/generators/web/ui/utils/sidebar.mjs diff --git a/src/generators/web/ui/components/SideBar/index.jsx b/src/generators/web/ui/components/SideBar/index.jsx index aede4b7a..568ae80e 100644 --- a/src/generators/web/ui/components/SideBar/index.jsx +++ b/src/generators/web/ui/components/SideBar/index.jsx @@ -2,54 +2,28 @@ import Select from '@node-core/ui-components/Common/Select'; import SideBar from '@node-core/ui-components/Containers/Sidebar'; import styles from './index.module.css'; -import { relative } from '../../../../../utils/url.mjs'; -import { buildSideBarGroups } from '../../utils/sidebar.mjs'; +import { + buildSideBarGroups, + getCompatibleVersions, + redirect, +} from './utils/index.mjs'; import { title, version, versions, pages } from '#theme/config'; -/** - * Extracts the major version number from a version string. - * @param {string} v - Version string (e.g., 'v14.0.0', '14.0.0') - * @returns {number} - */ -const getMajorVersion = v => parseInt(String(v).match(/\d+/)?.[0] ?? '0', 10); - -/** - * Redirect to a URL - * @param {string} url URL - */ -const redirect = url => (window.location.href = url); - /** * Sidebar component for MDX documentation with version selection and page navigation * @param {{ metadata: import('../../types').SerializedMetadata }} props */ export default ({ metadata }) => { - const introducedMajor = getMajorVersion( - metadata.added ?? metadata.introduced_in - ); - + // Build sidebar groups from metadata, categorizing pages and preserving order + const groups = buildSideBarGroups(pages, metadata); // Filter pre-computed versions by compatibility and resolve per-page URL - const compatibleVersions = versions - .filter(v => v.major >= introducedMajor) - .map(({ url, label }) => ({ - value: url.replace('{path}', metadata.path), - label, - })); - - const items = pages.map(([heading, path, category]) => ({ - label: heading, - link: - metadata.path === path - ? `${metadata.basename}.html` - : `${relative(path, metadata.path)}.html`, - category, - })); + const compatibleVersions = getCompatibleVersions(versions, metadata); return ( } title="Navigation" diff --git a/src/generators/web/ui/components/SideBar/utils/__tests__/index.test.mjs b/src/generators/web/ui/components/SideBar/utils/__tests__/index.test.mjs new file mode 100644 index 00000000..a5cd29aa --- /dev/null +++ b/src/generators/web/ui/components/SideBar/utils/__tests__/index.test.mjs @@ -0,0 +1,217 @@ +'use strict'; + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { + buildSideBarGroups, + getSidebarItems, + getMajorVersion, + getCompatibleVersions, +} = await import('../index.mjs'); + +const pages = [ + ['File System API', 'fs', 'File System'], + ['HTTP API', 'http', 'Networking'], + ['Path API', 'path', 'File System'], + ['Index', 'index'], +]; + +const versions = [ + { major: 14, url: '/api/v14/{path}.html', label: 'v14' }, + { major: 16, url: '/api/v16/{path}.html', label: 'v16' }, + { major: 18, url: '/api/v18/{path}.html', label: 'v18' }, +]; + +describe('buildSideBarGroups', () => { + it('groups entries by category and preserves insertion order', () => { + const metadata = { path: 'fs', basename: 'fs' }; + + const result = buildSideBarGroups(pages, metadata); + + assert.deepStrictEqual(result, [ + { + groupName: 'File System', + items: [ + { label: 'File System API', link: 'fs.html' }, + { label: 'Path API', link: 'path.html' }, + ], + }, + { + groupName: 'Networking', + items: [{ label: 'HTTP API', link: 'http.html' }], + }, + ]); + }); + + it('puts entries without category into an Others group at the end by default', () => { + const uncategorizedPages = [ + ['Buffer', 'buffer', 'Binary'], + ['Unknown', 'unknown'], + ['Config', 'config', ''], + ]; + const metadata = { path: 'buffer', basename: 'buffer' }; + + const result = buildSideBarGroups(uncategorizedPages, metadata); + + assert.equal(result.at(-1).groupName, 'Others'); + assert.deepStrictEqual(result.at(-1).items, [ + { label: 'Unknown', link: 'unknown.html' }, + { label: 'Config', link: 'config.html' }, + ]); + }); + + it('uses a custom default group name when provided', () => { + const metadata = { path: 'unknown', basename: 'unknown' }; + const result = buildSideBarGroups( + [['Unknown', 'unknown']], + metadata, + 'General' + ); + + assert.deepStrictEqual(result, [ + { + groupName: 'General', + items: [{ label: 'Unknown', link: 'unknown.html' }], + }, + ]); + }); + + it('returns an empty array when given no entries', () => { + assert.deepStrictEqual( + buildSideBarGroups([], { path: 'fs', basename: 'fs' }), + [] + ); + }); +}); + +describe('getSidebarItems', () => { + it('maps pages to sidebar items and keeps category values', () => { + const metadata = { path: 'fs', basename: 'fs' }; + const result = getSidebarItems(pages.slice(0, 3), metadata); + + assert.deepStrictEqual(result, [ + { + label: 'File System API', + link: 'fs.html', + category: 'File System', + }, + { + label: 'HTTP API', + link: 'http.html', + category: 'Networking', + }, + { + label: 'Path API', + link: 'path.html', + category: 'File System', + }, + ]); + }); + + it('uses basename html for the current page and relative links for others', () => { + const metadata = { path: 'guide/fs', basename: 'fs' }; + const result = getSidebarItems( + [ + ['File System API', 'guide/fs', 'File System'], + ['HTTP API', 'guide/http', 'Networking'], + ['Child API', 'guide/sub/child'], + ], + metadata + ); + + assert.deepStrictEqual(result, [ + { + label: 'File System API', + link: 'fs.html', + category: 'File System', + }, + { + label: 'HTTP API', + link: 'http.html', + category: 'Networking', + }, + { + label: 'Child API', + link: 'sub/child.html', + category: undefined, + }, + ]); + }); +}); + +describe('getMajorVersion', () => { + it('extracts major version from "v" prefixed string', () => { + assert.strictEqual(getMajorVersion('v14.0.0'), 14); + assert.strictEqual(getMajorVersion('v18.12.0'), 18); + }); + + it('extracts major version without "v" prefix', () => { + assert.strictEqual(getMajorVersion('16.0.0'), 16); + assert.strictEqual(getMajorVersion('20.1.0'), 20); + }); + + it('handles single digit versions', () => { + assert.strictEqual(getMajorVersion('v4'), 4); + assert.strictEqual(getMajorVersion('9'), 9); + }); + + it('returns integer only', () => { + const result = getMajorVersion('v14.5.3'); + assert.strictEqual(typeof result, 'number'); + assert.strictEqual(result % 1, 0); + }); +}); + +describe('getCompatibleVersions', () => { + it('includes versions with equal or greater major version', () => { + const metadata = { added: 'v14.0.0', path: 'fs.md' }; + const result = getCompatibleVersions(versions, metadata); + + assert.deepStrictEqual(result, [ + { value: '/api/v14/fs.md.html', label: 'v14' }, + { value: '/api/v16/fs.md.html', label: 'v16' }, + { value: '/api/v18/fs.md.html', label: 'v18' }, + ]); + }); + + it('filters out versions with lower major version', () => { + const metadata = { added: 'v18.0.0', path: 'fs.md' }; + const result = getCompatibleVersions(versions, metadata); + + assert.deepStrictEqual(result, [ + { value: '/api/v18/fs.md.html', label: 'v18' }, + ]); + }); + + it('uses introduced_in as fallback when added is missing', () => { + const metadata = { introduced_in: 'v16.0.0', path: 'fs.md' }; + const result = getCompatibleVersions(versions, metadata); + + assert.deepStrictEqual(result, [ + { value: '/api/v16/fs.md.html', label: 'v16' }, + { value: '/api/v18/fs.md.html', label: 'v18' }, + ]); + }); + + it('defaults to v0 when no version info provided', () => { + const metadata = { path: 'fs.md' }; + const result = getCompatibleVersions(versions, metadata); + + assert.deepStrictEqual(result, [ + { value: '/api/v14/fs.md.html', label: 'v14' }, + { value: '/api/v16/fs.md.html', label: 'v16' }, + { value: '/api/v18/fs.md.html', label: 'v18' }, + ]); + }); + + it('replaces {path} placeholder in URL', () => { + const metadata = { added: 'v14.0.0', path: 'file/system' }; + const result = getCompatibleVersions(versions, metadata); + + result.forEach(item => { + assert.ok(!item.value.includes('{path}')); + assert.ok(item.value.includes(metadata.path)); + }); + }); +}); diff --git a/src/generators/web/ui/components/SideBar/utils/index.mjs b/src/generators/web/ui/components/SideBar/utils/index.mjs new file mode 100644 index 00000000..1f60c9c2 --- /dev/null +++ b/src/generators/web/ui/components/SideBar/utils/index.mjs @@ -0,0 +1,116 @@ +import { relative } from '../../../../../../utils/url.mjs'; +import { SIDEBAR_GROUPS } from '../../../constants.mjs'; + +/** + * @deprecated This is being exported temporarily during the transition period. + * Reverse lookup: filename (e.g. 'fs.html') -> groupName, used as category + * fallback for pages without explicit category in metadata. + */ +export const fileToGroup = new Map( + SIDEBAR_GROUPS.flatMap(({ groupName, items }) => + items.map(item => [item, groupName]) + ) +); + +/** + * Builds grouped sidebar navigation from categorized page entries. + * Pages without a category are placed under the provided default group. + * + * @param {Array<[string, string, string?]>} pages - Array of page entries as [heading, path, category?] + * @param {{ path: string, basename: string }} metadata - Metadata for the current page, used to resolve links + * @param {string} [defaultGroupName='Others'] - Name for the default group containing uncategorized pages + * @returns {Array<{ groupName: string, items: Array<{ label: string, link: string }> }>} + */ +export const buildSideBarGroups = ( + pages, + metadata, + defaultGroupName = 'Others' +) => { + const items = getSidebarItems(pages, metadata); + const groups = new Map(); + const others = []; + + // Group entries by category while preserving insertion order + for (const { label, link, category } of items) { + const linkFilename = link.split('/').at(-1); + + // Skip index pages as they are typically the main entry point for a section + // and may not need to be listed separately in the sidebar. + if (linkFilename === 'index.html') { + continue; + } + + const resolvedCategory = category ?? fileToGroup.get(linkFilename); + + if (!resolvedCategory) { + others.push({ label, link }); + continue; + } + + const groupItems = groups.get(resolvedCategory) ?? []; + groupItems.push({ label, link }); + groups.set(resolvedCategory, groupItems); + } + + // Convert the groups map to an array while preserving the original order of categories + const orderedGroups = [...groups.entries()].map(([groupName, items]) => ({ + groupName, + items, + })); + + if (others.length > 0) { + orderedGroups.push({ groupName: defaultGroupName, items: others }); + } + + return orderedGroups; +}; + +/** + * Converts page entries to sidebar items with resolved links based on current page metadata. + * @param {Array<[string, string, string?]>} pages + * @param {{ path: string, basename: string }} metadata + * @returns {Array<{ label: string, link: string, category?: string }>} + */ +export const getSidebarItems = (pages, metadata) => + pages.map(([heading, path, category]) => ({ + label: heading, + link: + metadata.path === path + ? `${metadata.basename}.html` + : `${relative(path, metadata.path)}.html`, + category, + })); + +/** + * Extracts the major version number from a version string. + * @param {string} v - Version string (e.g., 'v14.0.0', '14.0.0') + * @returns {number} + */ +export const getMajorVersion = v => + parseInt(String(v).match(/\d+/)?.[0] ?? '0', 10); + +/** + * Filters pre-computed versions by compatibility and resolves per-page URL based on metadata. + * @param {Array<{ major: number, url: string, label: string }>} versions + * @param {{ added?: string, introduced_in?: string, path: string }} metadata + * @returns {Array<{ value: string, label: string }>} + */ +export const getCompatibleVersions = (versions, metadata) => { + const introducedMajor = getMajorVersion( + metadata.added ?? metadata.introduced_in + ); + + // Filter pre-computed versions by compatibility and resolve per-page URL + return versions + .filter(v => v.major >= introducedMajor) + .map(({ url, label }) => ({ + value: url.replace('{path}', metadata.path), + label, + })); +}; + +/** + * Redirect to a URL + * @param {string} url URL + */ +export const redirect = url => (window.location.href = url); diff --git a/src/generators/web/ui/utils/__tests__/sidebar.test.mjs b/src/generators/web/ui/utils/__tests__/sidebar.test.mjs deleted file mode 100644 index a82419ea..00000000 --- a/src/generators/web/ui/utils/__tests__/sidebar.test.mjs +++ /dev/null @@ -1,66 +0,0 @@ -'use strict'; - -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { buildSideBarGroups } from '../sidebar.mjs'; - -describe('buildSideBarGroups', () => { - it('groups entries by category and preserves insertion order', () => { - const frontmatter = [ - { label: 'FS', link: '/api/fs.html', category: 'File System' }, - { label: 'HTTP', link: '/api/http.html', category: 'Networking' }, - { label: 'Path', link: '/api/path.html', category: 'File System' }, - ]; - - const result = buildSideBarGroups(frontmatter); - - assert.deepStrictEqual(result, [ - { - groupName: 'File System', - items: [ - { label: 'FS', link: '/api/fs.html' }, - { label: 'Path', link: '/api/path.html' }, - ], - }, - { - groupName: 'Networking', - items: [{ label: 'HTTP', link: '/api/http.html' }], - }, - ]); - }); - - it('puts entries without category into an Others group at the end by default', () => { - const frontmatter = [ - { label: 'Buffer', link: '/api/buffer.html', category: 'Binary' }, - { label: 'Unknown', link: '/api/unknown.html' }, - { label: 'Config', link: '/api/config.html', category: '' }, - ]; - - const result = buildSideBarGroups(frontmatter); - - assert.equal(result.at(-1).groupName, 'Others'); - assert.deepStrictEqual(result.at(-1).items, [ - { label: 'Unknown', link: '/api/unknown.html' }, - { label: 'Config', link: '/api/config.html' }, - ]); - }); - - it('uses a custom default group name when provided', () => { - const result = buildSideBarGroups( - [{ label: 'Unknown', link: '/api/unknown.html' }], - 'General' - ); - - assert.deepStrictEqual(result, [ - { - groupName: 'General', - items: [{ label: 'Unknown', link: '/api/unknown.html' }], - }, - ]); - }); - - it('returns an empty array when given no entries', () => { - assert.deepStrictEqual(buildSideBarGroups([]), []); - }); -}); diff --git a/src/generators/web/ui/utils/sidebar.mjs b/src/generators/web/ui/utils/sidebar.mjs deleted file mode 100644 index 81ec5bb3..00000000 --- a/src/generators/web/ui/utils/sidebar.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import { SIDEBAR_GROUPS } from '../constants.mjs'; - -/** - * @deprecated This is being exported temporarily during the transition period. - * Reverse lookup: filename (e.g. 'fs.html') -> groupName, used as category - * fallback for pages without explicit category in metadata. - */ -export const fileToGroup = new Map( - SIDEBAR_GROUPS.flatMap(({ groupName, items }) => - items.map(item => [item, groupName]) - ) -); - -/** - * Builds grouped sidebar navigation from categorized page entries. - * Pages without a category are placed under the provided default group. - * - * @param {Array<{ label: string, link: string, category?: string }>} items - * @param {string} [defaultGroupName='Others'] - * @returns {Array<{ groupName: string, items: Array<{ label: string, link: string }> }>} - */ -export const buildSideBarGroups = (items, defaultGroupName = 'Others') => { - const groups = new Map(); - const others = []; - - // Group entries by category while preserving insertion order - for (const { label, link, category } of items) { - const linkFilename = link.split('/').at(-1); - - // Skip index pages as they are typically the main entry point for a section - // and may not need to be listed separately in the sidebar. - if (linkFilename === 'index.html') { - continue; - } - - const resolvedCategory = category ?? fileToGroup.get(linkFilename); - - if (!resolvedCategory) { - others.push({ label, link }); - continue; - } - - const groupItems = groups.get(resolvedCategory) ?? []; - groupItems.push({ label, link }); - groups.set(resolvedCategory, groupItems); - } - - // Convert the groups map to an array while preserving the original order of categories - const orderedGroups = [...groups.entries()].map(([groupName, items]) => ({ - groupName, - items, - })); - - if (others.length > 0) { - orderedGroups.push({ groupName: defaultGroupName, items: others }); - } - - return orderedGroups; -}; From 52e939c14d1095545075d0715f1417d87288ab69 Mon Sep 17 00:00:00 2001 From: Caner Akdas Date: Fri, 3 Apr 2026 23:12:32 +0300 Subject: [PATCH 07/13] chore: move select styles into the module css --- src/generators/web/ui/components/SideBar/index.module.css | 5 +++++ src/generators/web/ui/index.css | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/generators/web/ui/components/SideBar/index.module.css b/src/generators/web/ui/components/SideBar/index.module.css index 00b5c343..910f31a0 100644 --- a/src/generators/web/ui/components/SideBar/index.module.css +++ b/src/generators/web/ui/components/SideBar/index.module.css @@ -2,3 +2,8 @@ width: 100%; margin-bottom: -1rem; } + +/* Override the min-width of the select component used for version selection in the sidebar */ +.select button[role='combobox'] { + min-width: initial; +} diff --git a/src/generators/web/ui/index.css b/src/generators/web/ui/index.css index b381f0b2..3b0deb3e 100644 --- a/src/generators/web/ui/index.css +++ b/src/generators/web/ui/index.css @@ -118,8 +118,3 @@ main { } } } - -/* Override the min-width of the select component used for version selection in the sidebar */ -[class*='select'] button[role='combobox'] { - min-width: initial; -} From 67c54d6641b8fe24d0a53b6f98865035b65feb36 Mon Sep 17 00:00:00 2001 From: Caner Akdas Date: Fri, 3 Apr 2026 23:15:54 +0300 Subject: [PATCH 08/13] refactor: remove static group array --- .../web/ui/components/SideBar/utils/index.mjs | 20 +-- src/generators/web/ui/constants.mjs | 123 ------------------ 2 files changed, 3 insertions(+), 140 deletions(-) delete mode 100644 src/generators/web/ui/constants.mjs diff --git a/src/generators/web/ui/components/SideBar/utils/index.mjs b/src/generators/web/ui/components/SideBar/utils/index.mjs index 1f60c9c2..5fc9db11 100644 --- a/src/generators/web/ui/components/SideBar/utils/index.mjs +++ b/src/generators/web/ui/components/SideBar/utils/index.mjs @@ -1,16 +1,4 @@ import { relative } from '../../../../../../utils/url.mjs'; -import { SIDEBAR_GROUPS } from '../../../constants.mjs'; - -/** - * @deprecated This is being exported temporarily during the transition period. - * Reverse lookup: filename (e.g. 'fs.html') -> groupName, used as category - * fallback for pages without explicit category in metadata. - */ -export const fileToGroup = new Map( - SIDEBAR_GROUPS.flatMap(({ groupName, items }) => - items.map(item => [item, groupName]) - ) -); /** * Builds grouped sidebar navigation from categorized page entries. @@ -40,16 +28,14 @@ export const buildSideBarGroups = ( continue; } - const resolvedCategory = category ?? fileToGroup.get(linkFilename); - - if (!resolvedCategory) { + if (!category) { others.push({ label, link }); continue; } - const groupItems = groups.get(resolvedCategory) ?? []; + const groupItems = groups.get(category) ?? []; groupItems.push({ label, link }); - groups.set(resolvedCategory, groupItems); + groups.set(category, groupItems); } // Convert the groups map to an array while preserving the original order of categories diff --git a/src/generators/web/ui/constants.mjs b/src/generators/web/ui/constants.mjs deleted file mode 100644 index 3bd943d9..00000000 --- a/src/generators/web/ui/constants.mjs +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @deprecated This is being exported temporarily during the transition period. - * For a more general solution, category information should be added to pages in - * YAML format, and this array should be removed. - * - * Defines the sidebar navigation groups and their associated page URLs. - * @type {Array<{ groupName: string, items: Array }>} - */ -export const SIDEBAR_GROUPS = [ - { - groupName: 'Getting Started', - items: [ - 'documentation.html', - 'synopsis.html', - 'cli.html', - 'environment_variables.html', - 'globals.html', - ], - }, - { - groupName: 'Module System', - items: [ - 'modules.html', - 'esm.html', - 'module.html', - 'packages.html', - 'typescript.html', - ], - }, - { - groupName: 'Networking & Protocols', - items: [ - 'http.html', - 'http2.html', - 'https.html', - 'net.html', - 'dns.html', - 'dgram.html', - 'quic.html', - ], - }, - { - groupName: 'File System & I/O', - items: [ - 'fs.html', - 'path.html', - 'buffer.html', - 'stream.html', - 'string_decoder.html', - 'zlib.html', - 'readline.html', - 'tty.html', - 'zlib_iter.html', - ], - }, - { - groupName: 'Asynchronous Programming', - items: [ - 'async_context.html', - 'async_hooks.html', - 'events.html', - 'timers.html', - 'webstreams.html', - 'stream_iter.html', - ], - }, - { - groupName: 'Process & Concurrency', - items: [ - 'process.html', - 'child_process.html', - 'cluster.html', - 'worker_threads.html', - 'os.html', - ], - }, - { - groupName: 'Security & Cryptography', - items: ['crypto.html', 'webcrypto.html', 'permissions.html', 'tls.html'], - }, - { - groupName: 'Data & URL Utilities', - items: ['url.html', 'querystring.html', 'punycode.html', 'util.html'], - }, - { - groupName: 'Debugging & Diagnostics', - items: [ - 'debugger.html', - 'inspector.html', - 'console.html', - 'report.html', - 'tracing.html', - 'diagnostics_channel.html', - 'errors.html', - ], - }, - { - groupName: 'Testing & Assertion', - items: ['test.html', 'assert.html', 'repl.html'], - }, - { - groupName: 'Performance & Observability', - items: ['perf_hooks.html', 'v8.html'], - }, - { - groupName: 'Runtime & Advanced APIs', - items: [ - 'vm.html', - 'wasi.html', - 'sqlite.html', - 'single-executable-applications.html', - 'intl.html', - ], - }, - { - groupName: 'Native & Low-level Extensions', - items: ['addons.html', 'n-api.html', 'embedding.html'], - }, - { - groupName: 'Legacy & Deprecated', - items: ['deprecations.html', 'domain.html'], - }, -]; From 00045fe15a4396459b088296a3dc40b1556b03a8 Mon Sep 17 00:00:00 2001 From: Caner Akdas Date: Sat, 4 Apr 2026 19:28:10 +0300 Subject: [PATCH 09/13] feat: navigation list sort weight added --- src/generators/web/README.md | 11 ++- .../SideBar/utils/__tests__/index.test.mjs | 74 ++++++++++++++--- .../web/ui/components/SideBar/utils/index.mjs | 22 ++--- src/generators/web/ui/types.d.ts | 4 +- .../web/utils/__tests__/config.test.mjs | 45 +++++++++- .../web/utils/__tests__/pages.test.mjs | 73 ++++++++++++++++ src/generators/web/utils/config.mjs | 11 +-- src/generators/web/utils/pages.mjs | 83 +++++++++++++++++++ 8 files changed, 283 insertions(+), 40 deletions(-) create mode 100644 src/generators/web/utils/__tests__/pages.test.mjs create mode 100644 src/generators/web/utils/pages.mjs diff --git a/src/generators/web/README.md b/src/generators/web/README.md index 0941b8a7..a49cc081 100644 --- a/src/generators/web/README.md +++ b/src/generators/web/README.md @@ -54,7 +54,7 @@ import { title, repository, editURL } from '#theme/config'; | `version` | `string` | Current version label (e.g. `'v22.x'`) | | `versions` | `Array<{ url, label, major }>` | Pre-computed version entries with labels and URL templates (only `{path}` remains for per-page use) | | `editURL` | `string` | Partially populated "edit this page" URL template (only `{path}` remains) | -| `pages` | `Array<[string, string]>` | Sorted `[name, path]` tuples for sidebar navigation | +| `pages` | `Array<[number, { heading, path, category? }]>` | Sorted `[weight, page]` tuples for sidebar navigation (explicit weights first, then default ordering) | | `languageDisplayNameMap` | `Map` | Shiki language alias → display name map for code blocks | #### Usage in custom components @@ -69,9 +69,9 @@ export default ({ metadata }) => (